Repository: geopandas/geopandas
Pull request #2118 (2021-09-20 05:49:00)

PERF: Add new overlay benchmarks
Motivated by the pretty serious performance regression on overlays
I accidentally introduced in #1582, and reported in #2089, here's a new
benchmark for overlays that makes the regression more apparent than the
existing overlay benchmarks.

On master:
```
[  0.07%] ··· overlay.Countries.time_overlay                                                                        3/5 failed
[  0.07%] ··· ====================== ===========
                       how                      
              ---------------------- -----------
                   intersection        337±6ms  
                      union             failed  
                     identity           failed  
               symmetric_difference     failed  
                    difference        143±0.8ms 
              ====================== ===========

[  0.09%] ··· overlay.ManyPoints.time_overlay                                                                               ok
[  0.09%] ··· ====================== ===========
                       how                      
              ---------------------- -----------
                   intersection        299±4ms  
                      union            510±8ms  
                     identity          524±7ms  
               symmetric_difference   229±0.3ms 
                    difference        177±0.7ms 
              ====================== ===========

[  0.11%] ··· overlay.Small.time_overlay                                                                            3/5 failed
[  0.11%] ··· ====================== =============
                       how                        
              ---------------------- -------------
                   intersection       14.6±0.07ms 
                      union              failed   
                     identity            failed   
               symmetric_difference      failed   
                    difference        5.20±0.09ms 
              ====================== =============
```

On #2103:
```
[ 22.22%] ··· overlay.Countries.time_overlay                                                                        3/5 failed
[ 22.22%] ··· ====================== ===========
                       how                      
              ---------------------- -----------
                   intersection        78.9±1ms 
                      union             failed  
                     identity           failed  
               symmetric_difference     failed  
                    difference        141±0.7ms 
              ====================== ===========

[ 27.78%] ··· overlay.ManyPoints.time_overlay                                                                               ok
[ 27.78%] ··· ====================== =============
                       how                        
              ---------------------- -------------
                   intersection       9.57±0.01ms 
                      union            224±0.4ms  
                     identity           226±1ms   
               symmetric_difference     219±2ms   
                    difference          174±2ms   
              ====================== =============

[ 33.33%] ··· overlay.Small.time_overlay                                                                            3/5 failed
[ 33.33%] ··· ====================== =============
                       how                        
              ---------------------- -------------
                   intersection        7.22±0.1ms 
                      union              failed   
                     identity            failed   
               symmetric_difference      failed   
                    difference        5.14±0.05ms 
              ====================== =============
```

I initially thought the existing benchmarks didn't catch this at all, but I was a bit out of practice with `asv`, and wasn't correctly comparing the branch @jorisvandenbossche is working on with the master branch. 


Patch:
diff --git a/benchmarks/overlay.py b/benchmarks/overlay.py
index 7f1620a7..c9cddefb 100644
--- a/benchmarks/overlay.py
+++ b/benchmarks/overlay.py
@@ -1,10 +1,11 @@
 from geopandas import GeoDataFrame, GeoSeries, read_file, datasets, overlay
-from shapely.geometry import Polygon
+import numpy as np
+from shapely.geometry import Point, Polygon
 
 
 class Countries:
 
-    param_names = ['op']
+    param_names = ['how']
     params = [('intersection', 'union', 'identity', 'symmetric_difference',
                'difference')]
 
@@ -20,13 +21,13 @@ class Countries:
         self.countries = countries
         self.capitals = capitals
 
-    def time_overlay(self, op):
-        overlay(self.countries, self.capitals, how=op)
+    def time_overlay(self, how):
+        overlay(self.countries, self.capitals, how=how)
 
 
 class Small:
 
-    param_names = ['op']
+    param_names = ['how']
     params = [('intersection', 'union', 'identity', 'symmetric_difference',
                'difference')]
 
@@ -41,5 +42,24 @@ class Small:
 
         self.df1, self.df2 = df1, df2
 
-    def time_overlay(self, op):
-        overlay(self.df1, self.df2, how=op)
+    def time_overlay(self, how):
+        overlay(self.df1, self.df2, how=how)
+
+
+class ManyPoints:
+
+    param_names = ['how']
+    params = [('intersection', 'union', 'identity', 'symmetric_difference',
+               'difference')]
+
+    def setup(self, *args):
+
+        points = GeoDataFrame(geometry=[Point(i, i) for i in range(1000)])
+        base = np.array([[0, 0], [0, 100], [100, 100], [100, 0]])
+        polys = GeoDataFrame(
+            geometry=[Polygon(base + i * 100) for i in range(10)])
+
+        self.df1, self.df2 = points, polys
+
+    def time_overlay(self, how):
+        overlay(self.df1, self.df2, how=how)
diff --git a/ci/envs/38-dev.yaml b/ci/envs/38-dev.yaml
index bed25392..56b66327 100644
--- a/ci/envs/38-dev.yaml
+++ b/ci/envs/38-dev.yaml
@@ -28,3 +28,6 @@ dependencies:
     - git+https://github.com/matplotlib/matplotlib.git@master
     - git+https://github.com/Toblerity/Shapely.git@master
     - git+https://github.com/pygeos/pygeos.git@master
+    - git+https://github.com/python-visualization/folium.git@master
+    - git+https://github.com/geopandas/xyzservices.git@main
+
diff --git a/doc/environment.yml b/doc/environment.yml
index aa6faf83..25fc07e4 100644
--- a/doc/environment.yml
+++ b/doc/environment.yml
@@ -2,42 +2,43 @@ name: geopandas_docs
 channels:
   - conda-forge
 dependencies:
-  - python=3.9.5
-  - pandas=1.2.5
+  - python=3.9.7
+  - pandas=1.3.2
   - shapely=1.7.1
-  - fiona=1.8.18
-  - pyproj=3.1.0
+  - fiona=1.8.20
+  - pyproj=3.2.1
   - rtree=0.9.7
-  - geopy=2.1.0
-  - matplotlib=3.4.2
-  - mapclassify=2.4.2
-  - sphinx>=4.0.2
+  - geopy=2.2.0
+  - matplotlib=3.4.3
+  - mapclassify=2.4.3
+  - sphinx=4.2.0
   - pydata-sphinx-theme=0.6.3
   - numpydoc=1.1.0
-  - ipython=7.25.0
-  - pillow=8.2.0
+  - ipython=7.27.0
+  - pillow=8.3.2
   - mock=4.0.3
-  - cartopy=0.19.0.post1
+  - cartopy=0.20.0
   - pyepsg=0.4.0
   - contextily=1.1.0
-  - rasterio=1.2.6
-  - geoplot=0.4.1
+  - rasterio=1.2.8
+  - geoplot=0.4.4
   - sphinx-gallery=0.9.0
   - jinja2=3.0.1
   - doc2dash=2.3.0
   - matplotlib-scalebar=0.7.2
   # specify additional dependencies to reduce solving for conda
-  - gdal=3.2.1
-  - libgdal=3.2.1
-  - proj=7.2.0
+  - gdal=3.3.2
+  - libgdal=3.3.2
+  - proj=8.0.1
   - geos=3.9.1
-  - nbsphinx=0.8.6
-  - jupyter_client=6.1.12
-  - ipykernel=5.5.5
-  - myst-parser=0.15.1
+  - nbsphinx=0.8.7
+  - jupyter_client=7.0.3
+  - ipykernel=6.4.1
+  - myst-parser=0.15.2
   - folium=0.12.0
-  - libpysal=4.4.0
-  - pygeos=0.10
+  - libpysal=4.5.1
+  - pygeos=0.10.2
+  - xyzservices=2021.9.1
   - pip
   - pip:
       - sphinx-toggleprompt
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 947454c0..f8d15c49 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -404,4 +404,12 @@ intersphinx_mapping = {
         "https://shapely.readthedocs.io/en/stable/",
         "https://shapely.readthedocs.io/en/stable/objects.inv",
     ),
+    "branca": (
+        "https://python-visualization.github.io/branca/",
+        "https://python-visualization.github.io/branca/objects.inv",
+    ),
+    "xyzservices": (
+        "https://xyzservices.readthedocs.io/en/stable/",
+        "https://xyzservices.readthedocs.io/en/stable/objects.inv",
+    ),
 }
diff --git a/doc/source/docs/reference/geodataframe.rst b/doc/source/docs/reference/geodataframe.rst
index 2491ab56..84910f47 100644
--- a/doc/source/docs/reference/geodataframe.rst
+++ b/doc/source/docs/reference/geodataframe.rst
@@ -62,6 +62,12 @@ Aggregating and exploding
 Plotting
 --------
 
+.. autosummary::
+   :toctree: api/
+   
+   GeoDataFrame.explore
+
+
 .. autosummary::
    :toctree: api/
    :template: accessor_callable.rst
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index f4a50411..916bee75 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -150,6 +150,7 @@ Plotting
    :toctree: api/
 
    GeoSeries.plot
+   GeoSeries.explore
 
 
 Spatial index
diff --git a/doc/source/docs/user_guide.rst b/doc/source/docs/user_guide.rst
index 1086f58b..0fa6c507 100644
--- a/doc/source/docs/user_guide.rst
+++ b/doc/source/docs/user_guide.rst
@@ -14,6 +14,7 @@ Advanced topics can be found in the :doc:`Advanced Guide <advanced_guide>` and f
   Reading and Writing Files <user_guide/io>
   Indexing and Selecting Data <user_guide/indexing>
   Making Maps and plots <user_guide/mapping>
+  Interactive mapping <user_guide/interactive_mapping>
   Managing Projections <user_guide/projections>
   Geometric Manipulations <user_guide/geometric_manipulations>
   Set Operations with overlay <user_guide/set_operations>
diff --git a/doc/source/docs/user_guide/interactive_mapping.ipynb b/doc/source/docs/user_guide/interactive_mapping.ipynb
new file mode 100644
index 00000000..65944edc
--- /dev/null
+++ b/doc/source/docs/user_guide/interactive_mapping.ipynb
@@ -0,0 +1,129 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "source": [
+    "# Interactive mapping\n",
+    "\n",
+    "Alongside static plots, `geopandas` can create interactive maps based on the [folium](https://python-visualization.github.io/folium/) library.\n",
+    "\n",
+    "Creating maps for interactive exploration mirrors the API of [static plots](../reference/api/geopandas.GeoDataFrame.plot.html) in an [explore()](../reference/api/geopandas.GeoDataFrame.explore.html) method of a GeoSeries or GeoDataFrame.\n",
+    "\n",
+    "Loading some example data:"
+   ],
+   "metadata": {}
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "source": [
+    "import geopandas\n",
+    "\n",
+    "nybb = geopandas.read_file(geopandas.datasets.get_path('nybb'))\n",
+    "world = geopandas.read_file(geopandas.datasets.get_path('naturalearth_lowres'))\n",
+    "cities = geopandas.read_file(geopandas.datasets.get_path('naturalearth_cities'))"
+   ],
+   "outputs": [],
+   "metadata": {}
+  },
+  {
+   "cell_type": "markdown",
+   "source": [
+    "The simplest option is to use `GeoDataFrame.explore()`:"
+   ],
+   "metadata": {}
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "source": [
+    "nybb.explore()"
+   ],
+   "outputs": [],
+   "metadata": {}
+  },
+  {
+   "cell_type": "markdown",
+   "source": [
+    "Interactive plotting offers largely the same customisation as static one plus some features on top of that. Check the code below which plots a customised choropleth map. You can use `\"BoroName\"` column with NY boroughs names as an input of the choropleth, show (only) its name in the tooltip on hover but show all values on click. You can also pass custom background tiles (either a name supported by folium, a name recognized by `xyzservices.providers.query_name()`, XYZ URL or `xyzservices.TileProvider` object), specify colormap (all supported by `matplotlib`) and specify black outline."
+   ],
+   "metadata": {}
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "source": [
+    "nybb.explore( \n",
+    "     column=\"BoroName\", # make choropleth based on \"BoroName\" column\n",
+    "     tooltip=\"BoroName\", # show \"BoroName\" value in tooltip (on hover)\n",
+    "     popup=True, # show all values in popup (on click)\n",
+    "     tiles=\"CartoDB positron\", # use \"CartoDB positron\" tiles\n",
+    "     cmap=\"Set1\", # use \"Set1\" matplotlib colormap\n",
+    "     style_kwds=dict(color=\"black\") # use black outline\n",
+    "    )"
+   ],
+   "outputs": [],
+   "metadata": {}
+  },
+  {
+   "cell_type": "markdown",
+   "source": [
+    "The `explore()` method returns a `folium.Map` object, which can also be passed directly (as you do with `ax` in `plot()`). You can then use folium functionality directly on the resulting map. In the example below, you can plot two GeoDataFrames on the same map and add layer control using folium. You can also add additional tiles allowing you to change the background directly in the map."
+   ],
+   "metadata": {}
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "source": [
+    "import folium\n",
+    "\n",
+    "m = world.explore(\n",
+    "     column=\"pop_est\",  # make choropleth based on \"BoroName\" column\n",
+    "     scheme=\"naturalbreaks\",  # use mapclassify's natural breaks scheme\n",
+    "     legend=True, # show legend\n",
+    "     k=10, # use 10 bins\n",
+    "     legend_kwds=dict(colorbar=False), # do not use colorbar\n",
+    "     name=\"countries\" # name of the layer in the map\n",
+    ")\n",
+    "\n",
+    "cities.explore(\n",
+    "     m=m, # pass the map object\n",
+    "     color=\"red\", # use red color on all points\n",
+    "     marker_kwds=dict(radius=10, fill=True), # make marker radius 10px with fill\n",
+    "     tooltip=\"name\", # show \"name\" column in the tooltip\n",
+    "     tooltip_kwds=dict(labels=False), # do not show column label in the tooltip\n",
+    "     name=\"cities\" # name of the layer in the map\n",
+    ")\n",
+    "\n",
+    "folium.TileLayer('Stamen Toner', control=True).add_to(m)  # use folium to add alternative tiles\n",
+    "folium.LayerControl().add_to(m)  # use folium to add layer control\n",
+    "\n",
+    "m  # show map"
+   ],
+   "outputs": [],
+   "metadata": {}
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.2"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
\ No newline at end of file
diff --git a/doc/source/gallery/overlays.ipynb b/doc/source/gallery/overlays.ipynb
index 10be1a64..6f16719a 100644
--- a/doc/source/gallery/overlays.ipynb
+++ b/doc/source/gallery/overlays.ipynb
@@ -201,7 +201,7 @@
  ],
  "metadata": {
   "kernelspec": {
-   "display_name": "Python 3",
+   "display_name": "Python 3 (ipykernel)",
    "language": "python",
    "name": "python3"
   },
@@ -215,7 +215,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.9.1"
+   "version": "3.9.2"
   }
  },
  "nbformat": 4,
diff --git a/doc/source/getting_started/introduction.ipynb b/doc/source/getting_started/introduction.ipynb
index 20357ab4..d548e155 100644
--- a/doc/source/getting_started/introduction.ipynb
+++ b/doc/source/getting_started/introduction.ipynb
@@ -215,6 +215,22 @@
     "gdf.plot(\"area\", legend=True)"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can also explore your data interactively using `GeoDataFrame.explore()`, which behaves in the same way `plot()` does but returns an interactive map instead."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "gdf.explore(\"area\", legend=False)"
+   ]
+  },
   {
    "cell_type": "markdown",
    "metadata": {},
@@ -506,6 +522,11 @@
   }
  ],
  "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
   "language_info": {
    "codemirror_mode": {
     "name": "ipython",
@@ -516,7 +537,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.9.1"
+   "version": "3.9.2"
   }
  },
  "nbformat": 4,
diff --git a/geopandas/explore.py b/geopandas/explore.py
new file mode 100644
index 00000000..b8b3bb7c
--- /dev/null
+++ b/geopandas/explore.py
@@ -0,0 +1,901 @@
+from statistics import mean
+
+import geopandas
+from shapely.geometry import LineString
+import numpy as np
+import pandas as pd
+
+_MAP_KWARGS = [
+    "location",
+    "prefer_canvas",
+    "no_touch",
+    "disable_3d",
+    "png_enabled",
+    "zoom_control",
+    "crs",
+    "zoom_start",
+    "left",
+    "top",
+    "position",
+    "min_zoom",
+    "max_zoom",
+    "min_lat",
+    "max_lat",
+    "min_lon",
+    "max_lon",
+    "max_bounds",
+]
+
+
+def _explore(
+    df,
+    column=None,
+    cmap=None,
+    color=None,
+    m=None,
+    tiles="OpenStreetMap",
+    attr=None,
+    tooltip=True,
+    popup=False,
+    highlight=True,
+    categorical=False,
+    legend=True,
+    scheme=None,
+    k=5,
+    vmin=None,
+    vmax=None,
+    width="100%",
+    height="100%",
+    categories=None,
+    classification_kwds=None,
+    control_scale=True,
+    marker_type=None,
+    marker_kwds={},
+    style_kwds={},
+    highlight_kwds={},
+    missing_kwds={},
+    tooltip_kwds={},
+    popup_kwds={},
+    legend_kwds={},
+    **kwargs,
+):
+    """Interactive map based on GeoPandas and folium/leaflet.js
+
+    Generate an interactive leaflet map based on :class:`~geopandas.GeoDataFrame`
+
+    Parameters
+    ----------
+    column : str, np.array, pd.Series (default None)
+        The name of the dataframe column, :class:`numpy.array`,
+        or :class:`pandas.Series` to be plotted. If :class:`numpy.array` or
+        :class:`pandas.Series` are used then it must have same length as dataframe.
+    cmap : str, matplotlib.Colormap, branca.colormap or function (default None)
+        The name of a colormap recognized by ``matplotlib``, a list-like of colors,
+        :class:`matplotlib.colors.Colormap`, a :class:`branca.colormap.ColorMap` or
+        function that returns a named color or hex based on the column
+        value, e.g.::
+
+            def my_colormap(value):  # scalar value defined in 'column'
+                if value > 1:
+                    return "green"
+                return "red"
+
+    color : str, array-like (default None)
+        Named color or a list-like of colors (named or hex).
+    m : folium.Map (default None)
+        Existing map instance on which to draw the plot.
+    tiles : str, xyzservices.TileProvider (default 'OpenStreetMap Mapnik')
+        Map tileset to use. Can choose from the list supported by folium, query a
+        :class:`xyzservices.TileProvider` by a name from ``xyzservices.providers``,
+        pass :class:`xyzservices.TileProvider` object or pass custom XYZ URL.
+        The current list of built-in providers (when ``xyzservices`` is not available):
+
+        ``["OpenStreetMap", "Stamen Terrain", “Stamen Toner", “Stamen Watercolor"
+        "CartoDB positron", “CartoDB dark_matter"]``
+
+        You can pass a custom tileset to Folium by passing a Leaflet-style URL
+        to the tiles parameter: ``http://{s}.yourtiles.com/{z}/{x}/{y}.png``.
+        Be sure to check their terms and conditions and to provide attribution with
+        the ``attr`` keyword.
+    attr : str (default None)
+        Map tile attribution; only required if passing custom tile URL.
+    tooltip : bool, str, int, list (default True)
+        Display GeoDataFrame attributes when hovering over the object.
+        ``True`` includes all columns. ``False`` removes tooltip. Pass string or list of
+        strings to specify a column(s). Integer specifies first n columns to be
+        included. Defaults to ``True``.
+    popup : bool, str, int, list (default False)
+        Input GeoDataFrame attributes for object displayed when clicking.
+        ``True`` includes all columns. ``False`` removes popup. Pass string or list of
+        strings to specify a column(s). Integer specifies first n columns to be
+        included. Defaults to ``False``.
+    highlight : bool (default True)
+        Enable highlight functionality when hovering over a geometry.
+    categorical : bool (default False)
+        If ``False``, ``cmap`` will reflect numerical values of the
+        column being plotted. For non-numerical columns, this
+        will be set to True.
+    legend : bool (default True)
+        Plot a legend in choropleth plots.
+        Ignored if no ``column`` is given.
+    scheme : str (default None)
+        Name of a choropleth classification scheme (requires ``mapclassify`` >= 2.4.0).
+        A :func:`mapclassify.classify` will be used
+        under the hood. Supported are all schemes provided by ``mapclassify`` (e.g.
+        ``'BoxPlot'``, ``'EqualInterval'``, ``'FisherJenks'``, ``'FisherJenksSampled'``,
+        ``'HeadTailBreaks'``, ``'JenksCaspall'``, ``'JenksCaspallForced'``,
+        ``'JenksCaspallSampled'``, ``'MaxP'``, ``'MaximumBreaks'``,
+        ``'NaturalBreaks'``, ``'Quantiles'``, ``'Percentiles'``, ``'StdMean'``,
+        ``'UserDefined'``). Arguments can be passed in ``classification_kwds``.
+    k : int (default 5)
+        Number of classes
+    vmin : None or float (default None)
+        Minimum value of ``cmap``. If ``None``, the minimum data value
+        in the column to be plotted is used.
+    vmax : None or float (default None)
+        Maximum value of ``cmap``. If ``None``, the maximum data value
+        in the column to be plotted is used.
+    width : pixel int or percentage string (default: '100%')
+        Width of the folium :class:`~folium.folium.Map`. If the argument
+        m is given explicitly, width is ignored.
+    height : pixel int or percentage string (default: '100%')
+        Height of the folium :class:`~folium.folium.Map`. If the argument
+        m is given explicitly, height is ignored.
+    categories : list-like
+        Ordered list-like object of categories to be used for categorical plot.
+    classification_kwds : dict (default None)
+        Keyword arguments to pass to mapclassify
+    control_scale : bool, (default True)
+        Whether to add a control scale on the map.
+    marker_type : str, folium.Circle, folium.CircleMarker, folium.Marker (default None)
+        Allowed string options are ('marker', 'circle', 'circle_marker'). Defaults to
+        folium.CircleMarker.
+    marker_kwds: dict (default {})
+        Additional keywords to be passed to the selected ``marker_type``, e.g.:
+
+        radius : float (default 2 for ``circle_marker`` and 50 for ``circle``))
+            Radius of the circle, in meters (for ``circle``) or pixels
+            (for ``circle_marker``).
+        fill : bool (default True)
+            Whether to fill the ``circle`` or ``circle_marker`` with color.
+        icon : folium.map.Icon
+            the :class:`folium.map.Icon` object to use to render the marker.
+        draggable : bool (default False)
+            Set to True to be able to drag the marker around the map.
+
+    style_kwds : dict (default {})
+        Additional style to be passed to folium ``style_function``:
+
+        stroke : bool (default True)
+            Whether to draw stroke along the path. Set it to ``False`` to
+            disable borders on polygons or circles.
+        color : str
+            Stroke color
+        weight : int
+            Stroke width in pixels
+        opacity : float (default 1.0)
+            Stroke opacity
+        fill : boolean (default True)
+            Whether to fill the path with color. Set it to ``False`` to
+            disable filling on polygons or circles.
+        fillColor : str
+            Fill color. Defaults to the value of the color option
+        fillOpacity : float (default 0.5)
+            Fill opacity.
+
+        Plus all supported by :func:`folium.vector_layers.path_options`. See the
+        documentation of :class:`folium.features.GeoJson` for details.
+
+    highlight_kwds : dict (default {})
+        Style to be passed to folium highlight_function. Uses the same keywords
+        as ``style_kwds``. When empty, defaults to ``{"fillOpacity": 0.75}``.
+    tooltip_kwds : dict (default {})
+        Additional keywords to be passed to :class:`folium.features.GeoJsonTooltip`,
+        e.g. ``aliases``, ``labels``, or ``sticky``.
+    popup_kwds : dict (default {})
+        Additional keywords to be passed to :class:`folium.features.GeoJsonPopup`,
+        e.g. ``aliases`` or ``labels``.
+    legend_kwds : dict (default {})
+        Additional keywords to be passed to the legend.
+
+        Currently supported customisation:
+
+        caption : string
+            Custom caption of the legend. Defaults to the column name.
+
+        Additional accepted keywords when ``scheme`` is specified:
+
+        colorbar : bool (default True)
+            An option to control the style of the legend. If True, continuous
+            colorbar will be used. If False, categorical legend will be used for bins.
+        scale : bool (default True)
+            Scale bins along the colorbar axis according to the bin edges (True)
+            or use the equal length for each bin (False)
+        fmt : string (default "{:.2f}")
+            A formatting specification for the bin edges of the classes in the
+            legend. For example, to have no decimals: ``{"fmt": "{:.0f}"}``. Applies
+            if ``colorbar=False``.
+        labels : list-like
+            A list of legend labels to override the auto-generated labels.
+            Needs to have the same number of elements as the number of
+            classes (`k`). Applies if ``colorbar=False``.
+        interval : boolean (default False)
+            An option to control brackets from mapclassify legend.
+            If True, open/closed interval brackets are shown in the legend.
+            Applies if ``colorbar=False``.
+        max_labels : int, default 10
+            Maximum number of colorbar tick labels (requires branca>=0.5.0)
+
+    **kwargs : dict
+        Additional options to be passed on to the folium object.
+
+    Returns
+    -------
+    m : folium.folium.Map
+        folium :class:`~folium.folium.Map` instance
+
+    Examples
+    --------
+    >>> df = geopandas.read_file(geopandas.datasets.get_path("naturalearth_lowres"))
+    >>> df.head(2)  # doctest: +SKIP
+        pop_est      continent                      name iso_a3  \
+gdp_md_est                                           geometry
+    0     920938        Oceania                      Fiji    FJI      8374.0  MULTIPOLY\
+GON (((180.00000 -16.06713, 180.00000...
+    1   53950935         Africa                  Tanzania    TZA    150600.0  POLYGON (\
+(33.90371 -0.95000, 34.07262 -1.05982...
+
+    >>> df.explore("pop_est", cmap="Blues")  # doctest: +SKIP
+    """
+    try:
+        import branca as bc
+        import folium
+        import matplotlib.cm as cm
+        import matplotlib.colors as colors
+        import matplotlib.pyplot as plt
+        from mapclassify import classify
+    except (ImportError, ModuleNotFoundError):
+        raise ImportError(
+            "The 'folium', 'matplotlib' and 'mapclassify' packages are required for "
+            "'explore()'. You can install them using "
+            "'conda install -c conda-forge folium matplotlib mapclassify' "
+            "or 'pip install folium matplotlib mapclassify'."
+        )
+
+    # xyservices is an optional dependency
+    try:
+        import xyzservices
+
+        HAS_XYZSERVICES = True
+    except (ImportError, ModuleNotFoundError):
+        HAS_XYZSERVICES = False
+
+    gdf = df.copy()
+
+    # convert LinearRing to LineString
+    rings_mask = df.geom_type == "LinearRing"
+    if rings_mask.any():
+        gdf.geometry[rings_mask] = gdf.geometry[rings_mask].apply(
+            lambda g: LineString(g)
+        )
+
+    if gdf.crs is None:
+        kwargs["crs"] = "Simple"
+        tiles = None
+    elif not gdf.crs.equals(4326):
+        gdf = gdf.to_crs(4326)
+
+    # create folium.Map object
+    if m is None:
+        # Get bounds to specify location and map extent
+        bounds = gdf.total_bounds
+        location = kwargs.pop("location", None)
+        if location is None:
+            x = mean([bounds[0], bounds[2]])
+            y = mean([bounds[1], bounds[3]])
+            location = (y, x)
+            if "zoom_start" in kwargs.keys():
+                fit = False
+            else:
+                fit = True
+        else:
+            fit = False
+
+        # get a subset of kwargs to be passed to folium.Map
+        map_kwds = {i: kwargs[i] for i in kwargs.keys() if i in _MAP_KWARGS}
+
+        if HAS_XYZSERVICES:
+            # match provider name string to xyzservices.TileProvider
+            if isinstance(tiles, str):
+                try:
+                    tiles = xyzservices.providers.query_name(tiles)
+                except ValueError:
+                    pass
+
+            if isinstance(tiles, xyzservices.TileProvider):
+                attr = attr if attr else tiles.html_attribution
+                map_kwds["min_zoom"] = tiles.get("min_zoom", 0)
+                map_kwds["max_zoom"] = tiles.get("max_zoom", 18)
+                tiles = tiles.build_url(scale_factor="{r}")
+
+        m = folium.Map(
+            location=location,
+            control_scale=control_scale,
+            tiles=tiles,
+            attr=attr,
+            width=width,
+            height=height,
+            **map_kwds,
+        )
+
+        # fit bounds to get a proper zoom level
+        if fit:
+            m.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]])
+
+    for map_kwd in _MAP_KWARGS:
+        kwargs.pop(map_kwd, None)
+
+    nan_idx = None
+
+    if column is not None:
+        if pd.api.types.is_list_like(column):
+            if len(column) != gdf.shape[0]:
+                raise ValueError(
+                    "The GeoDataFrame and given column have different number of rows."
+                )
+            else:
+                column_name = "__plottable_column"
+                gdf[column_name] = column
+                column = column_name
+        elif pd.api.types.is_categorical_dtype(gdf[column]):
+            if categories is not None:
+                raise ValueError(
+                    "Cannot specify 'categories' when column has categorical dtype"
+                )
+            categorical = True
+        elif gdf[column].dtype is np.dtype("O") or categories:
+            categorical = True
+
+        nan_idx = pd.isna(gdf[column])
+
+        if categorical:
+            cat = pd.Categorical(gdf[column][~nan_idx], categories=categories)
+            N = len(cat.categories)
+            cmap = cmap if cmap else "tab20"
+
+            # colormap exists in matplotlib
+            if cmap in plt.colormaps():
+
+                color = np.apply_along_axis(
+                    colors.to_hex, 1, cm.get_cmap(cmap, N)(cat.codes)
+                )
+                legend_colors = np.apply_along_axis(
+                    colors.to_hex, 1, cm.get_cmap(cmap, N)(range(N))
+                )
+
+            # colormap is matplotlib.Colormap
+            elif isinstance(cmap, colors.Colormap):
+                color = np.apply_along_axis(colors.to_hex, 1, cmap(cat.codes))
+                legend_colors = np.apply_along_axis(colors.to_hex, 1, cmap(range(N)))
+
+            # custom list of colors
+            elif pd.api.types.is_list_like(cmap):
+                if N > len(cmap):
+                    cmap = cmap * (N // len(cmap) + 1)
+                color = np.take(cmap, cat.codes)
+                legend_colors = np.take(cmap, range(N))
+
+            else:
+                raise ValueError(
+                    "'cmap' is invalid. For categorical plots, pass either valid "
+                    "named matplotlib colormap or a list-like of colors."
+                )
+
+        elif callable(cmap):
+            # List of colors based on Branca colormaps or self-defined functions
+            color = list(map(lambda x: cmap(x), df[column]))
+
+        else:
+            vmin = gdf[column].min() if not vmin else vmin
+            vmax = gdf[column].max() if not vmax else vmax
+
+            # get bins
+            if scheme is not None:
+
+                if classification_kwds is None:
+                    classification_kwds = {}
+                if "k" not in classification_kwds:
+                    classification_kwds["k"] = k
+
+                binning = classify(
+                    np.asarray(gdf[column][~nan_idx]), scheme, **classification_kwds
+                )
+                color = np.apply_along_axis(
+                    colors.to_hex, 1, cm.get_cmap(cmap, k)(binning.yb)
+                )
+
+            else:
+
+                bins = np.linspace(vmin, vmax, 257)[1:]
+                binning = classify(
+                    np.asarray(gdf[column][~nan_idx]), "UserDefined", bins=bins
+                )
+
+                color = np.apply_along_axis(
+                    colors.to_hex, 1, cm.get_cmap(cmap, 256)(binning.yb)
+                )
+
+    # set default style
+    if "fillOpacity" not in style_kwds:
+        style_kwds["fillOpacity"] = 0.5
+    if "weight" not in style_kwds:
+        style_kwds["weight"] = 2
+
+    # specify color
+    if color is not None:
+        if (
+            isinstance(color, str)
+            and isinstance(gdf, geopandas.GeoDataFrame)
+            and color in gdf.columns
+        ):  # use existing column
+
+            def _style_color(x):
+                return {
+                    "fillColor": x["properties"][color],
+                    **style_kwds,
+                }
+
+            style_function = _style_color
+        else:  # assign new column
+            if isinstance(gdf, geopandas.GeoSeries):
+                gdf = geopandas.GeoDataFrame(geometry=gdf)
+
+            if nan_idx is not None and nan_idx.any():
+                nan_color = missing_kwds.pop("color", None)
+
+                gdf["__folium_color"] = nan_color
+                gdf.loc[~nan_idx, "__folium_color"] = color
+            else:
+                gdf["__folium_color"] = color
+
+            stroke_color = style_kwds.pop("color", None)
+            if not stroke_color:
+
+                def _style_column(x):
+                    return {
+                        "fillColor": x["properties"]["__folium_color"],
+                        "color": x["properties"]["__folium_color"],
+                        **style_kwds,
+                    }
+
+                style_function = _style_column
+            else:
+
+                def _style_stroke(x):
+                    return {
+                        "fillColor": x["properties"]["__folium_color"],
+                        "color": stroke_color,
+                        **style_kwds,
+                    }
+
+                style_function = _style_stroke
+    else:  # use folium default
+
+        def _style_default(x):
+            return {**style_kwds}
+
+        style_function = _style_default
+
+    if highlight:
+        if "fillOpacity" not in highlight_kwds:
+            highlight_kwds["fillOpacity"] = 0.75
+
+        def _style_highlight(x):
+            return {**highlight_kwds}
+
+        highlight_function = _style_highlight
+    else:
+        highlight_function = None
+
+    # define default for points
+    if marker_type is None:
+        marker_type = "circle_marker"
+
+    marker = marker_type
+    if isinstance(marker_type, str):
+        if marker_type == "marker":
+            marker = folium.Marker(**marker_kwds)
+        elif marker_type == "circle":
+            marker = folium.Circle(**marker_kwds)
+        elif marker_type == "circle_marker":
+            marker_kwds["radius"] = marker_kwds.get("radius", 2)
+            marker_kwds["fill"] = marker_kwds.get("fill", True)
+            marker = folium.CircleMarker(**marker_kwds)
+        else:
+            raise ValueError(
+                "Only 'marker', 'circle', and 'circle_marker' are "
+                "supported as marker values"
+            )
+
+    # remove additional geometries
+    if isinstance(gdf, geopandas.GeoDataFrame):
+        non_active_geoms = [
+            name
+            for name, val in (gdf.dtypes == "geometry").items()
+            if val and name != gdf.geometry.name
+        ]
+        gdf = gdf.drop(columns=non_active_geoms)
+
+    # preprare tooltip and popup
+    if isinstance(gdf, geopandas.GeoDataFrame):
+        # add named index to the tooltip
+        if gdf.index.name is not None:
+            gdf = gdf.reset_index()
+        # specify fields to show in the tooltip
+        tooltip = _tooltip_popup("tooltip", tooltip, gdf, **tooltip_kwds)
+        popup = _tooltip_popup("popup", popup, gdf, **popup_kwds)
+    else:
+        tooltip = None
+        popup = None
+
+    # add dataframe to map
+    folium.GeoJson(
+        gdf.__geo_interface__,
+        tooltip=tooltip,
+        popup=popup,
+        marker=marker,
+        style_function=style_function,
+        highlight_function=highlight_function,
+        **kwargs,
+    ).add_to(m)
+
+    if legend:
+        # NOTE: overlaps will be resolved in branca #88
+        caption = column if not column == "__plottable_column" else ""
+        caption = legend_kwds.pop("caption", caption)
+        if categorical:
+            categories = cat.categories.to_list()
+            legend_colors = legend_colors.tolist()
+
+            if nan_idx.any() and nan_color:
+                categories.append(missing_kwds.pop("label", "NaN"))
+                legend_colors.append(nan_color)
+
+            _categorical_legend(m, caption, categories, legend_colors)
+        elif column is not None:
+
+            cbar = legend_kwds.pop("colorbar", True)
+            colormap_kwds = {}
+            if "max_labels" in legend_kwds:
+                colormap_kwds["max_labels"] = legend_kwds.pop("max_labels")
+            if scheme:
+                cb_colors = np.apply_along_axis(
+                    colors.to_hex, 1, cm.get_cmap(cmap, binning.k)(range(binning.k))
+                )
+                if cbar:
+                    if legend_kwds.pop("scale", True):
+                        index = [vmin] + binning.bins.tolist()
+                    else:
+                        index = None
+                    colorbar = bc.colormap.StepColormap(
+                        cb_colors,
+                        vmin=vmin,
+                        vmax=vmax,
+                        caption=caption,
+                        index=index,
+                        **colormap_kwds,
+                    )
+                else:
+                    fmt = legend_kwds.pop("fmt", "{:.2f}")
+                    if "labels" in legend_kwds:
+                        categories = legend_kwds["labels"]
+                    else:
+                        categories = binning.get_legend_classes(fmt)
+                        show_interval = legend_kwds.pop("interval", False)
+                        if not show_interval:
+                            categories = [c[1:-1] for c in categories]
+
+                    if nan_idx.any() and nan_color:
+                        categories.append(missing_kwds.pop("label", "NaN"))
+                        cb_colors = np.append(cb_colors, nan_color)
+                    _categorical_legend(m, caption, categories, cb_colors)
+
+            else:
+                if isinstance(cmap, bc.colormap.ColorMap):
+                    colorbar = cmap
+                else:
+
+                    mp_cmap = cm.get_cmap(cmap)
+                    cb_colors = np.apply_along_axis(
+                        colors.to_hex, 1, mp_cmap(range(mp_cmap.N))
+                    )
+                    # linear legend
+                    if mp_cmap.N > 20:
+                        colorbar = bc.colormap.LinearColormap(
+                            cb_colors,
+                            vmin=vmin,
+                            vmax=vmax,
+                            caption=caption,
+                            **colormap_kwds,
+                        )
+
+                    # steps
+                    else:
+                        colorbar = bc.colormap.StepColormap(
+                            cb_colors,
+                            vmin=vmin,
+                            vmax=vmax,
+                            caption=caption,
+                            **colormap_kwds,
+                        )
+
+            if cbar:
+                if nan_idx.any() and nan_color:
+                    _categorical_legend(
+                        m, "", [missing_kwds.pop("label", "NaN")], [nan_color]
+                    )
+                m.add_child(colorbar)
+
+    return m
+
+
+def _tooltip_popup(type, fields, gdf, **kwds):
+    """get tooltip or popup"""
+    import folium
+
+    # specify fields to show in the tooltip
+    if fields is False or fields is None or fields == 0:
+        return None
+    else:
+        if fields is True:
+            fields = gdf.columns.drop(gdf.geometry.name).to_list()
+        elif isinstance(fields, int):
+            fields = gdf.columns.drop(gdf.geometry.name).to_list()[:fields]
+        elif isinstance(fields, str):
+            fields = [fields]
+
+    for field in ["__plottable_column", "__folium_color"]:
+        if field in fields:
+            fields.remove(field)
+
+    # Cast fields to str
+    fields = list(map(str, fields))
+    if type == "tooltip":
+        return folium.GeoJsonTooltip(fields, **kwds)
+    elif type == "popup":
+        return folium.GeoJsonPopup(fields, **kwds)
+
+
+def _categorical_legend(m, title, categories, colors):
+    """
+    Add categorical legend to a map
+
+    The implementation is using the code originally written by Michel Metran
+    (@michelmetran) and released on GitHub
+    (https://github.com/michelmetran/package_folium) under MIT license.
+
+    Copyright (c) 2020 Michel Metran
+
+    Parameters
+    ----------
+    m : folium.Map
+        Existing map instance on which to draw the plot
+    title : str
+        title of the legend (e.g. column name)
+    categories : list-like
+        list of categories
+    colors : list-like
+        list of colors (in the same order as categories)
+    """
+
+    # Header to Add
+    head = """
+    {% macro header(this, kwargs) %}
+    <script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
+    <script>$( function() {
+        $( ".maplegend" ).draggable({
+            start: function (event, ui) {
+                $(this).css({
+                    right: "auto",
+                    top: "auto",
+                    bottom: "auto"
+                });
+            }
+        });
+    });
+    </script>
+    <style type='text/css'>
+      .maplegend {
+        position: absolute;
+        z-index:9999;
+        background-color: rgba(255, 255, 255, .8);
+        border-radius: 5px;
+        box-shadow: 0 0 15px rgba(0,0,0,0.2);
+        padding: 10px;
+        font: 12px/14px Arial, Helvetica, sans-serif;
+        right: 10px;
+        bottom: 20px;
+      }
+      .maplegend .legend-title {
+        text-align: left;
+        margin-bottom: 5px;
+        font-weight: bold;
+        }
+      .maplegend .legend-scale ul {
+        margin: 0;
+        margin-bottom: 0px;
+        padding: 0;
+        float: left;
+        list-style: none;
+        }
+      .maplegend .legend-scale ul li {
+        list-style: none;
+        margin-left: 0;
+        line-height: 16px;
+        margin-bottom: 2px;
+        }
+      .maplegend ul.legend-labels li span {
+        display: block;
+        float: left;
+        height: 14px;
+        width: 14px;
+        margin-right: 5px;
+        margin-left: 0;
+        border: 0px solid #ccc;
+        }
+      .maplegend .legend-source {
+        color: #777;
+        clear: both;
+        }
+      .maplegend a {
+        color: #777;
+        }
+    </style>
+    {% endmacro %}
+    """
+    import branca as bc
+
+    # Add CSS (on Header)
+    macro = bc.element.MacroElement()
+    macro._template = bc.element.Template(head)
+    m.get_root().add_child(macro)
+
+    body = f"""
+    <div id='maplegend {title}' class='maplegend'>
+        <div class='legend-title'>{title}</div>
+        <div class='legend-scale'>
+            <ul class='legend-labels'>"""
+
+    # Loop Categories
+    for label, color in zip(categories, colors):
+        body += f"""
+                <li><span style='background:{color}'></span>{label}</li>"""
+
+    body += """
+            </ul>
+        </div>
+    </div>
+    """
+
+    # Add Body
+    body = bc.element.Element(body, "legend")
+    m.get_root().html.add_child(body)
+
+
+def _explore_geoseries(
+    s,
+    color=None,
+    m=None,
+    tiles="OpenStreetMap",
+    attr=None,
+    highlight=True,
+    width="100%",
+    height="100%",
+    control_scale=True,
+    marker_type=None,
+    marker_kwds={},
+    style_kwds={},
+    highlight_kwds={},
+    **kwargs,
+):
+    """Interactive map based on GeoPandas and folium/leaflet.js
+
+    Generate an interactive leaflet map based on :class:`~geopandas.GeoSeries`
+
+    Parameters
+    ----------
+    color : str, array-like (default None)
+        Named color or a list-like of colors (named or hex).
+    m : folium.Map (default None)
+        Existing map instance on which to draw the plot.
+    tiles : str, xyzservices.TileProvider (default 'OpenStreetMap Mapnik')
+        Map tileset to use. Can choose from the list supported by folium, query a
+        :class:`xyzservices.TileProvider` by a name from ``xyzservices.providers``,
+        pass :class:`xyzservices.TileProvider` object or pass custom XYZ URL.
+        The current list of built-in providers (when ``xyzservices`` is not available):
+
+        ``["OpenStreetMap", "Stamen Terrain", “Stamen Toner", “Stamen Watercolor"
+        "CartoDB positron", “CartoDB dark_matter"]``
+
+        You can pass a custom tileset to Folium by passing a Leaflet-style URL
+        to the tiles parameter: ``http://{s}.yourtiles.com/{z}/{x}/{y}.png``.
+        Be sure to check their terms and conditions and to provide attribution with
+        the ``attr`` keyword.
+    attr : str (default None)
+        Map tile attribution; only required if passing custom tile URL.
+    highlight : bool (default True)
+        Enable highlight functionality when hovering over a geometry.
+    width : pixel int or percentage string (default: '100%')
+        Width of the folium :class:`~folium.folium.Map`. If the argument
+        m is given explicitly, width is ignored.
+    height : pixel int or percentage string (default: '100%')
+        Height of the folium :class:`~folium.folium.Map`. If the argument
+        m is given explicitly, height is ignored.
+    control_scale : bool, (default True)
+        Whether to add a control scale on the map.
+    marker_type : str, folium.Circle, folium.CircleMarker, folium.Marker (default None)
+        Allowed string options are ('marker', 'circle', 'circle_marker'). Defaults to
+        folium.Marker.
+    marker_kwds: dict (default {})
+        Additional keywords to be passed to the selected ``marker_type``, e.g.:
+
+        radius : float
+            Radius of the circle, in meters (for ``'circle'``) or pixels
+            (for ``circle_marker``).
+        icon : folium.map.Icon
+            the :class:`folium.map.Icon` object to use to render the marker.
+        draggable : bool (default False)
+            Set to True to be able to drag the marker around the map.
+
+    style_kwds : dict (default {})
+        Additional style to be passed to folium ``style_function``:
+
+        stroke : bool (default True)
+            Whether to draw stroke along the path. Set it to ``False`` to
+            disable borders on polygons or circles.
+        color : str
+            Stroke color
+        weight : int
+            Stroke width in pixels
+        opacity : float (default 1.0)
+            Stroke opacity
+        fill : boolean (default True)
+            Whether to fill the path with color. Set it to ``False`` to
+            disable filling on polygons or circles.
+        fillColor : str
+            Fill color. Defaults to the value of the color option
+        fillOpacity : float (default 0.5)
+            Fill opacity.
+
+        Plus all supported by :func:`folium.vector_layers.path_options`. See the
+        documentation of :class:`folium.features.GeoJson` for details.
+
+    highlight_kwds : dict (default {})
+        Style to be passed to folium highlight_function. Uses the same keywords
+        as ``style_kwds``. When empty, defaults to ``{"fillOpacity": 0.75}``.
+
+    **kwargs : dict
+        Additional options to be passed on to the folium.
+
+    Returns
+    -------
+    m : folium.folium.Map
+        folium :class:`~folium.folium.Map` instance
+
+    """
+    return _explore(
+        s,
+        color=color,
+        m=m,
+        tiles=tiles,
+        attr=attr,
+        highlight=highlight,
+        width=width,
+        height=height,
+        control_scale=control_scale,
+        marker_type=marker_type,
+        marker_kwds=marker_kwds,
+        style_kwds=style_kwds,
+        highlight_kwds=highlight_kwds,
+        **kwargs,
+    )
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 1bcc6ce1..1de0de8d 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -15,7 +15,7 @@ from geopandas.array import GeometryArray, GeometryDtype, from_shapely, to_wkb,
 from geopandas.base import GeoPandasBase, is_geometry_type
 from geopandas.geoseries import GeoSeries
 import geopandas.io
-
+from geopandas.explore import _explore
 from . import _compat as compat
 from ._decorator import doc
 
@@ -1652,6 +1652,34 @@ box': (2.0, 1.0, 2.0, 1.0)}], 'bbox': (1.0, 1.0, 2.0, 2.0)}
         # do not return a GeoDataFrame
         return pd.DataFrame(df)
 
+    def convert_dtypes(self, *args, **kwargs):
+        """
+        Convert columns to best possible dtypes using dtypes supporting ``pd.NA``.
+
+        Always returns a GeoDataFrame as no conversions are applied to the
+        geometry column.
+
+        See the pandas.DataFrame.convert_dtypes docstring for more details.
+
+        Returns
+        -------
+        GeoDataFrame
+
+        """
+        # Overridden to fix GH1870, that return type is not preserved always
+        # (and where it was, geometry col was not)
+
+        if not compat.PANDAS_GE_10:
+            raise NotImplementedError(
+                "GeoDataFrame.convert_dtypes requires pandas >= 1.0"
+            )
+
+        return GeoDataFrame(
+            super().convert_dtypes(*args, **kwargs),
+            geometry=self.geometry.name,
+            crs=self.crs,
+        )
+
     def to_postgis(
         self,
         name,
@@ -1759,6 +1787,11 @@ box': (2.0, 1.0, 2.0, 1.0)}], 'bbox': (1.0, 1.0, 2.0, 2.0)}
 
     plot = CachedAccessor("plot", geopandas.plotting.GeoplotAccessor)
 
+    @doc(_explore)
+    def explore(self, *args, **kwargs):
+        """Interactive map based on folium/leaflet.js"""
+        return _explore(self, *args, **kwargs)
+
 
 def _dataframe_set_geometry(self, col, drop=False, inplace=False, crs=None):
     if inplace:
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 3f540df3..ed15258e 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -11,6 +11,7 @@ from shapely.geometry.base import BaseGeometry
 
 from geopandas.base import GeoPandasBase, _delegate_property
 from geopandas.plotting import plot_series
+from geopandas.explore import _explore_geoseries
 
 from . import _compat as compat
 from ._decorator import doc
@@ -749,6 +750,11 @@ class GeoSeries(GeoPandasBase, Series):
     def plot(self, *args, **kwargs):
         return plot_series(self, *args, **kwargs)
 
+    @doc(_explore_geoseries)
+    def explore(self, *args, **kwargs):
+        """Interactive map based on folium/leaflet.js"""
+        return _explore_geoseries(self, *args, **kwargs)
+
     def explode(self):
         """
         Explode multi-part geometries into multiple single geometries.
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
index 156102a1..9d2ebdcd 100644
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -32,6 +32,14 @@ METADATA_VERSION = "0.1.0"
 # }
 
 
+def _is_fsspec_url(url):
+    return (
+        isinstance(url, str)
+        and "://" in url
+        and not url.startswith(("http://", "https://"))
+    )
+
+
 def _create_metadata(df):
     """Create and encode geo metadata dict.
 
@@ -339,7 +347,45 @@ def _arrow_to_geopandas(table):
     return GeoDataFrame(df, geometry=geometry)
 
 
-def _read_parquet(path, columns=None, **kwargs):
+def _get_filesystem_path(path, filesystem=None, storage_options=None):
+    """
+    Get the filesystem and path for a given filesystem and path.
+
+    If the filesystem is not None then it's just returned as is.
+    """
+    import pyarrow
+
+    if (
+        isinstance(path, str)
+        and storage_options is None
+        and filesystem is None
+        and LooseVersion(pyarrow.__version__) >= "5.0.0"
+    ):
+        # Use the native pyarrow filesystem if possible.
+        try:
+            from pyarrow.fs import FileSystem
+
+            filesystem, path = FileSystem.from_uri(path)
+        except Exception:
+            # fallback to use get_handle / fsspec for filesystems
+            # that pyarrow doesn't support
+            pass
+
+    if _is_fsspec_url(path) and filesystem is None:
+        fsspec = import_optional_dependency(
+            "fsspec", extra="fsspec is requred for 'storage_options'."
+        )
+        filesystem, path = fsspec.core.url_to_fs(path, **(storage_options or {}))
+
+    if filesystem is None and storage_options:
+        raise ValueError(
+            "Cannot provide 'storage_options' with non-fsspec path '{}'".format(path)
+        )
+
+    return filesystem, path
+
+
+def _read_parquet(path, columns=None, storage_options=None, **kwargs):
     """
     Load a Parquet object from the file path, returning a GeoDataFrame.
 
@@ -366,6 +412,17 @@ def _read_parquet(path, columns=None, **kwargs):
         geometry read from the file will be set as the geometry column
         of the returned GeoDataFrame.  If no geometry columns are present,
         a ``ValueError`` will be raised.
+    storage_options : dict, optional
+        Extra options that make sense for a particular storage connection, e.g. host,
+        port, username, password, etc. For HTTP(S) URLs the key-value pairs are
+        forwarded to urllib as header options. For other URLs (e.g. starting with
+        "s3://", and "gcs://") the key-value pairs are forwarded to fsspec. Please
+        see fsspec and urllib for more details.
+
+        When no storage options are provided and a filesystem is implemented by
+        both ``pyarrow.fs`` and ``fsspec`` (e.g. "s3://") then the ``pyarrow.fs``
+        filesystem is preferred. Provide the instantiated fsspec filesystem using
+        the ``filesystem`` keyword if you wish to use its implementation.
     **kwargs
         Any additional kwargs passed to pyarrow.parquet.read_table().
 
@@ -388,9 +445,15 @@ def _read_parquet(path, columns=None, **kwargs):
     parquet = import_optional_dependency(
         "pyarrow.parquet", extra="pyarrow is required for Parquet support."
     )
+    # TODO(https://github.com/pandas-dev/pandas/pull/41194): see if pandas
+    # adds filesystem as a keyword and match that.
+    filesystem = kwargs.pop("filesystem", None)
+    filesystem, path = _get_filesystem_path(
+        path, filesystem=filesystem, storage_options=storage_options
+    )
 
     kwargs["use_pandas_metadata"] = True
-    table = parquet.read_table(path, columns=columns, **kwargs)
+    table = parquet.read_table(path, columns=columns, filesystem=filesystem, **kwargs)
 
     return _arrow_to_geopandas(table)
 


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 508beb6e..c6d6027b 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -8,6 +8,10 @@ on:
   schedule:
     - cron: "0 0 * * *"
 
+concurrency: 
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
 jobs:
   Linting:
     runs-on: ubuntu-latest
diff --git a/ci/envs/37-latest-conda-forge.yaml b/ci/envs/37-latest-conda-forge.yaml
index 1ca12938..d9f37abe 100644
--- a/ci/envs/37-latest-conda-forge.yaml
+++ b/ci/envs/37-latest-conda-forge.yaml
@@ -18,9 +18,11 @@ dependencies:
   - rtree
   - matplotlib
   - mapclassify
+  - folium
+  - xyzservices
   - scipy
   - geopy
   - SQLalchemy
   - libspatialite
   - pyarrow
- 
+
diff --git a/ci/envs/37-latest-defaults.yaml b/ci/envs/37-latest-defaults.yaml
index afd6710d..d1970c69 100644
--- a/ci/envs/37-latest-defaults.yaml
+++ b/ci/envs/37-latest-defaults.yaml
@@ -24,3 +24,5 @@ dependencies:
       - geopy
       - mapclassify
       - pyarrow
+      - folium
+      - xyzservices
diff --git a/ci/envs/38-latest-conda-forge.yaml b/ci/envs/38-latest-conda-forge.yaml
index c2d3404e..cf84fcf9 100644
--- a/ci/envs/38-latest-conda-forge.yaml
+++ b/ci/envs/38-latest-conda-forge.yaml
@@ -18,6 +18,8 @@ dependencies:
   - rtree
   - matplotlib
   - mapclassify
+  - folium
+  - xyzservices
   - scipy
   - geopy
   # installed in tests.yaml, because not available on windows
diff --git a/ci/envs/39-latest-conda-forge.yaml b/ci/envs/39-latest-conda-forge.yaml
index bb8c8f35..05825b20 100644
--- a/ci/envs/39-latest-conda-forge.yaml
+++ b/ci/envs/39-latest-conda-forge.yaml
@@ -17,8 +17,9 @@ dependencies:
   # optional
   - rtree
   - matplotlib
-  - descartes
   - mapclassify
+  - folium
+  - xyzservices
   - scipy
   - geopy
   # installed in tests.yaml, because not available on windows
@@ -30,3 +31,4 @@ dependencies:
   - pyarrow
   # doctest testing
   - pytest-doctestplus
+
diff --git a/geopandas/io/tests/test_arrow.py b/geopandas/io/tests/test_arrow.py
index 6cdd72eb..81d6132b 100644
--- a/geopandas/io/tests/test_arrow.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -16,6 +16,7 @@ from geopandas.io.arrow import (
     _create_metadata,
     _decode_metadata,
     _encode_metadata,
+    _get_filesystem_path,
     _validate_dataframe,
     _validate_metadata,
     METADATA_VERSION,
@@ -498,3 +499,45 @@ def test_feather_arrow_version(tmpdir):
         ImportError, match="pyarrow >= 0.17 required for Feather support"
     ):
         df.to_feather(filename)
+
+
+def test_fsspec_url():
+    fsspec = pytest.importorskip("fsspec")
+    import fsspec.implementations.memory
+
+    class MyMemoryFileSystem(fsspec.implementations.memory.MemoryFileSystem):
+        # Simple fsspec filesystem that adds a required keyword.
+        # Attempting to use this filesystem without the keyword will raise an exception.
+        def __init__(self, is_set, *args, **kwargs):
+            self.is_set = is_set
+            super().__init__(*args, **kwargs)
+
+    fsspec.register_implementation("memory", MyMemoryFileSystem, clobber=True)
+    memfs = MyMemoryFileSystem(is_set=True)
+
+    test_dataset = "naturalearth_lowres"
+    df = read_file(get_path(test_dataset))
+
+    with memfs.open("data.parquet", "wb") as f:
+        df.to_parquet(f)
+
+    result = read_parquet("memory://data.parquet", storage_options=dict(is_set=True))
+    assert_geodataframe_equal(result, df)
+
+    result = read_parquet("memory://data.parquet", filesystem=memfs)
+    assert_geodataframe_equal(result, df)
+
+
+def test_non_fsspec_url_with_storage_options_raises():
+    with pytest.raises(ValueError, match="storage_options"):
+        test_dataset = "naturalearth_lowres"
+        read_parquet(get_path(test_dataset), storage_options={"foo": "bar"})
+
+
+@pytest.mark.skipif(
+    pyarrow.__version__ < LooseVersion("5.0.0"),
+    reason="pyarrow.fs requires pyarrow>=5.0.0",
+)
+def test_prefers_pyarrow_fs():
+    filesystem, _ = _get_filesystem_path("file:///data.parquet")
+    assert isinstance(filesystem, pyarrow.fs.LocalFileSystem)
diff --git a/geopandas/tests/test_explore.py b/geopandas/tests/test_explore.py
new file mode 100644
index 00000000..5bb9c319
--- /dev/null
+++ b/geopandas/tests/test_explore.py
@@ -0,0 +1,785 @@
+import geopandas as gpd
+import numpy as np
+import pandas as pd
+import pytest
+from distutils.version import LooseVersion
+
+folium = pytest.importorskip("folium")
+branca = pytest.importorskip("branca")
+matplotlib = pytest.importorskip("matplotlib")
+mapclassify = pytest.importorskip("mapclassify")
+
+import matplotlib.cm as cm  # noqa
+import matplotlib.colors as colors  # noqa
+from branca.colormap import StepColormap  # noqa
+
+BRANCA_05 = str(branca.__version__) > LooseVersion("0.4.2")
+
+
+class TestExplore:
+    def setup_method(self):
+        self.nybb = gpd.read_file(gpd.datasets.get_path("nybb"))
+        self.world = gpd.read_file(gpd.datasets.get_path("naturalearth_lowres"))
+        self.cities = gpd.read_file(gpd.datasets.get_path("naturalearth_cities"))
+        self.world["range"] = range(len(self.world))
+        self.missing = self.world.copy()
+        np.random.seed(42)
+        self.missing.loc[np.random.choice(self.missing.index, 40), "continent"] = np.nan
+        self.missing.loc[np.random.choice(self.missing.index, 40), "pop_est"] = np.nan
+
+    def _fetch_map_string(self, m):
+        out = m._parent.render()
+        out_str = "".join(out.split())
+        return out_str
+
+    def test_simple_pass(self):
+        """Make sure default pass"""
+        self.nybb.explore()
+        self.world.explore()
+        self.cities.explore()
+        self.world.geometry.explore()
+
+    def test_choropleth_pass(self):
+        """Make sure default choropleth pass"""
+        self.world.explore(column="pop_est")
+
+    def test_map_settings_default(self):
+        """Check default map settings"""
+        m = self.world.explore()
+        assert m.location == [
+            pytest.approx(-3.1774349999999956, rel=1e-6),
+            pytest.approx(2.842170943040401e-14, rel=1e-6),
+        ]
+        assert m.options["zoom"] == 10
+        assert m.options["zoomControl"] is True
+        assert m.position == "relative"
+        assert m.height == (100.0, "%")
+        assert m.width == (100.0, "%")
+        assert m.left == (0, "%")
+        assert m.top == (0, "%")
+        assert m.global_switches.no_touch is False
+        assert m.global_switches.disable_3d is False
+        assert "openstreetmap" in m.to_dict()["children"].keys()
+
+    def test_map_settings_custom(self):
+        """Check custom map settins"""
+        m = self.nybb.explore(
+            zoom_control=False,
+            width=200,
+            height=200,
+        )
+        assert m.location == [
+            pytest.approx(40.70582377450201, rel=1e-6),
+            pytest.approx(-73.9778006856748, rel=1e-6),
+        ]
+        assert m.options["zoom"] == 10
+        assert m.options["zoomControl"] is False
+        assert m.height == (200.0, "px")
+        assert m.width == (200.0, "px")
+
+        # custom XYZ tiles
+        m = self.nybb.explore(
+            zoom_control=False,
+            width=200,
+            height=200,
+            tiles="https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}",
+            attr="Google",
+        )
+
+        out_str = self._fetch_map_string(m)
+        s = '"https://mt1.google.com/vt/lyrs=m\\u0026x={x}\\u0026y={y}\\u0026z={z}"'
+        assert s in out_str
+        assert '"attribution":"Google"' in out_str
+
+        m = self.nybb.explore(location=(40, 5))
+        assert m.location == [40, 5]
+        assert m.options["zoom"] == 10
+
+        m = self.nybb.explore(zoom_start=8)
+        assert m.location == [
+            pytest.approx(40.70582377450201, rel=1e-6),
+            pytest.approx(-73.9778006856748, rel=1e-6),
+        ]
+        assert m.options["zoom"] == 8
+
+        m = self.nybb.explore(location=(40, 5), zoom_start=8)
+        assert m.location == [40, 5]
+        assert m.options["zoom"] == 8
+
+    def test_simple_color(self):
+        """Check color settings"""
+        # single named color
+        m = self.nybb.explore(color="red")
+        out_str = self._fetch_map_string(m)
+        assert '"fillColor":"red"' in out_str
+
+        # list of colors
+        colors = ["#333333", "#367324", "#95824f", "#fcaa00", "#ffcc33"]
+        m2 = self.nybb.explore(color=colors)
+        out_str = self._fetch_map_string(m2)
+        for c in colors:
+            assert f'"fillColor":"{c}"' in out_str
+
+        # column of colors
+        df = self.nybb.copy()
+        df["colors"] = colors
+        m3 = df.explore(color="colors")
+        out_str = self._fetch_map_string(m3)
+        for c in colors:
+            assert f'"fillColor":"{c}"' in out_str
+
+        # line GeoSeries
+        m4 = self.nybb.boundary.explore(color="red")
+        out_str = self._fetch_map_string(m4)
+        assert '"fillColor":"red"' in out_str
+
+    def test_choropleth_linear(self):
+        """Check choropleth colors"""
+        # default cmap
+        m = self.nybb.explore(column="Shape_Leng")
+        out_str = self._fetch_map_string(m)
+        assert 'color":"#440154"' in out_str
+        assert 'color":"#fde725"' in out_str
+        assert 'color":"#50c46a"' in out_str
+        assert 'color":"#481467"' in out_str
+        assert 'color":"#3d4e8a"' in out_str
+
+        # named cmap
+        m = self.nybb.explore(column="Shape_Leng", cmap="PuRd")
+        out_str = self._fetch_map_string(m)
+        assert 'color":"#f7f4f9"' in out_str
+        assert 'color":"#67001f"' in out_str
+        assert 'color":"#d31760"' in out_str
+        assert 'color":"#f0ecf5"' in out_str
+        assert 'color":"#d6bedc"' in out_str
+
+    def test_choropleth_mapclassify(self):
+        """Mapclassify bins"""
+        # quantiles
+        m = self.nybb.explore(column="Shape_Leng", scheme="quantiles")
+        out_str = self._fetch_map_string(m)
+        assert 'color":"#21918c"' in out_str
+        assert 'color":"#3b528b"' in out_str
+        assert 'color":"#5ec962"' in out_str
+        assert 'color":"#fde725"' in out_str
+        assert 'color":"#440154"' in out_str
+
+        # headtail
+        m = self.world.explore(column="pop_est", scheme="headtailbreaks")
+        out_str = self._fetch_map_string(m)
+        assert '"fillColor":"#3b528b"' in out_str
+        assert '"fillColor":"#21918c"' in out_str
+        assert '"fillColor":"#5ec962"' in out_str
+        assert '"fillColor":"#fde725"' in out_str
+        assert '"fillColor":"#440154"' in out_str
+        # custom k
+        m = self.world.explore(column="pop_est", scheme="naturalbreaks", k=3)
+        out_str = self._fetch_map_string(m)
+        assert '"fillColor":"#21918c"' in out_str
+        assert '"fillColor":"#fde725"' in out_str
+        assert '"fillColor":"#440154"' in out_str
+
+    def test_categorical(self):
+        """Categorical maps"""
+        # auto detection
+        m = self.world.explore(column="continent")
+        out_str = self._fetch_map_string(m)
+        assert 'color":"#9467bd","continent":"Europe"' in out_str
+        assert 'color":"#c49c94","continent":"NorthAmerica"' in out_str
+        assert 'color":"#1f77b4","continent":"Africa"' in out_str
+        assert 'color":"#98df8a","continent":"Asia"' in out_str
+        assert 'color":"#ff7f0e","continent":"Antarctica"' in out_str
+        assert 'color":"#9edae5","continent":"SouthAmerica"' in out_str
+        assert 'color":"#7f7f7f","continent":"Oceania"' in out_str
+        assert 'color":"#dbdb8d","continent":"Sevenseas(openocean)"' in out_str
+
+        # forced categorical
+        m = self.nybb.explore(column="BoroCode", categorical=True)
+        out_str = self._fetch_map_string(m)
+        assert 'color":"#9edae5"' in out_str
+        assert 'color":"#c7c7c7"' in out_str
+        assert 'color":"#8c564b"' in out_str
+        assert 'color":"#1f77b4"' in out_str
+        assert 'color":"#98df8a"' in out_str
+
+        # pandas.Categorical
+        df = self.world.copy()
+        df["categorical"] = pd.Categorical(df["name"])
+        m = df.explore(column="categorical")
+        out_str = self._fetch_map_string(m)
+        for c in np.apply_along_axis(colors.to_hex, 1, cm.tab20(range(20))):
+            assert f'"fillColor":"{c}"' in out_str
+
+        # custom cmap
+        m = self.nybb.explore(column="BoroName", cmap="Set1")
+        out_str = self._fetch_map_string(m)
+        assert 'color":"#999999"' in out_str
+        assert 'color":"#a65628"' in out_str
+        assert 'color":"#4daf4a"' in out_str
+        assert 'color":"#e41a1c"' in out_str
+        assert 'color":"#ff7f00"' in out_str
+
+        # custom list of colors
+        cmap = ["#333432", "#3b6e8c", "#bc5b4f", "#8fa37e", "#efc758"]
+        m = self.nybb.explore(column="BoroName", cmap=cmap)
+        out_str = self._fetch_map_string(m)
+        for c in cmap:
+            assert f'"fillColor":"{c}"' in out_str
+
+        # shorter list (to make it repeat)
+        cmap = ["#333432", "#3b6e8c"]
+        m = self.nybb.explore(column="BoroName", cmap=cmap)
+        out_str = self._fetch_map_string(m)
+        for c in cmap:
+            assert f'"fillColor":"{c}"' in out_str
+
+        with pytest.raises(ValueError, match="'cmap' is invalid."):
+            self.nybb.explore(column="BoroName", cmap="nonsense")
+
+    def test_categories(self):
+        m = self.nybb[["BoroName", "geometry"]].explore(
+            column="BoroName",
+            categories=["Brooklyn", "Staten Island", "Queens", "Bronx", "Manhattan"],
+        )
+        out_str = self._fetch_map_string(m)
+        assert '"Bronx","__folium_color":"#c7c7c7"' in out_str
+        assert '"Manhattan","__folium_color":"#9edae5"' in out_str
+        assert '"Brooklyn","__folium_color":"#1f77b4"' in out_str
+        assert '"StatenIsland","__folium_color":"#98df8a"' in out_str
+        assert '"Queens","__folium_color":"#8c564b"' in out_str
+
+        df = self.nybb.copy()
+        df["categorical"] = pd.Categorical(df["BoroName"])
+        with pytest.raises(ValueError, match="Cannot specify 'categories'"):
+            df.explore("categorical", categories=["Brooklyn", "Staten Island"])
+
+    def test_column_values(self):
+        """
+        Check that the dataframe plot method returns same values with an
+        input string (column in df), pd.Series, or np.array
+        """
+        column_array = np.array(self.world["pop_est"])
+        m1 = self.world.explore(column="pop_est")  # column name
+        m2 = self.world.explore(column=column_array)  # np.array
+        m3 = self.world.explore(column=self.world["pop_est"])  # pd.Series
+        assert m1.location == m2.location == m3.location
+
+        m1_fields = self.world.explore(column=column_array, tooltip=True, popup=True)
+        out1_fields_str = self._fetch_map_string(m1_fields)
+        assert (
+            'fields=["pop_est","continent","name","iso_a3","gdp_md_est","range"]'
+            in out1_fields_str
+        )
+        assert (
+            'aliases=["pop_est","continent","name","iso_a3","gdp_md_est","range"]'
+            in out1_fields_str
+        )
+
+        m2_fields = self.world.explore(
+            column=self.world["pop_est"], tooltip=True, popup=True
+        )
+        out2_fields_str = self._fetch_map_string(m2_fields)
+        assert (
+            'fields=["pop_est","continent","name","iso_a3","gdp_md_est","range"]'
+            in out2_fields_str
+        )
+        assert (
+            'aliases=["pop_est","continent","name","iso_a3","gdp_md_est","range"]'
+            in out2_fields_str
+        )
+
+        # GeoDataframe and the given list have different number of rows
+        with pytest.raises(ValueError, match="different number of rows"):
+            self.world.explore(column=np.array([1, 2, 3]))
+
+    def test_no_crs(self):
+        """Naive geometry get no tiles"""
+        df = self.world.copy()
+        df.crs = None
+        m = df.explore()
+        assert "openstreetmap" not in m.to_dict()["children"].keys()
+
+    def test_style_kwds(self):
+        """Style keywords"""
+        m = self.world.explore(
+            style_kwds=dict(fillOpacity=0.1, weight=0.5, fillColor="orange")
+        )
+        out_str = self._fetch_map_string(m)
+        assert '"fillColor":"orange","fillOpacity":0.1,"weight":0.5' in out_str
+        m = self.world.explore(column="pop_est", style_kwds=dict(color="black"))
+        assert '"color":"black"' in self._fetch_map_string(m)
+
+    def test_tooltip(self):
+        """Test tooltip"""
+        # default with no tooltip or popup
+        m = self.world.explore()
+        assert "GeoJsonTooltip" in str(m.to_dict())
+        assert "GeoJsonPopup" not in str(m.to_dict())
+
+        # True
+        m = self.world.explore(tooltip=True, popup=True)
+        assert "GeoJsonTooltip" in str(m.to_dict())
+        assert "GeoJsonPopup" in str(m.to_dict())
+        out_str = self._fetch_map_string(m)
+        assert (
+            'fields=["pop_est","continent","name","iso_a3","gdp_md_est","range"]'
+            in out_str
+        )
+        assert (
+            'aliases=["pop_est","continent","name","iso_a3","gdp_md_est","range"]'
+            in out_str
+        )
+
+        # True choropleth
+        m = self.world.explore(column="pop_est", tooltip=True, popup=True)
+        assert "GeoJsonTooltip" in str(m.to_dict())
+        assert "GeoJsonPopup" in str(m.to_dict())
+        out_str = self._fetch_map_string(m)
+        assert (
+            'fields=["pop_est","continent","name","iso_a3","gdp_md_est","range"]'
+            in out_str
+        )
+        assert (
+            'aliases=["pop_est","continent","name","iso_a3","gdp_md_est","range"]'
+            in out_str
+        )
+
+        # single column
+        m = self.world.explore(tooltip="pop_est", popup="iso_a3")
+        out_str = self._fetch_map_string(m)
+        assert 'fields=["pop_est"]' in out_str
+        assert 'aliases=["pop_est"]' in out_str
+        assert 'fields=["iso_a3"]' in out_str
+        assert 'aliases=["iso_a3"]' in out_str
+
+        # list
+        m = self.world.explore(
+            tooltip=["pop_est", "continent"], popup=["iso_a3", "gdp_md_est"]
+        )
+        out_str = self._fetch_map_string(m)
+        assert 'fields=["pop_est","continent"]' in out_str
+        assert 'aliases=["pop_est","continent"]' in out_str
+        assert 'fields=["iso_a3","gdp_md_est"' in out_str
+        assert 'aliases=["iso_a3","gdp_md_est"]' in out_str
+
+        # number
+        m = self.world.explore(tooltip=2, popup=2)
+        out_str = self._fetch_map_string(m)
+        assert 'fields=["pop_est","continent"]' in out_str
+        assert 'aliases=["pop_est","continent"]' in out_str
+
+        # keywords tooltip
+        m = self.world.explore(
+            tooltip=True,
+            popup=False,
+            tooltip_kwds=dict(aliases=[0, 1, 2, 3, 4, 5], sticky=False),
+        )
+        out_str = self._fetch_map_string(m)
+        assert (
+            'fields=["pop_est","continent","name","iso_a3","gdp_md_est","range"]'
+            in out_str
+        )
+        assert "aliases=[0,1,2,3,4,5]" in out_str
+        assert '"sticky":false' in out_str
+
+        # keywords popup
+        m = self.world.explore(
+            tooltip=False,
+            popup=True,
+            popup_kwds=dict(aliases=[0, 1, 2, 3, 4, 5]),
+        )
+        out_str = self._fetch_map_string(m)
+        assert (
+            'fields=["pop_est","continent","name","iso_a3","gdp_md_est","range"]'
+            in out_str
+        )
+        assert "aliases=[0,1,2,3,4,5]" in out_str
+        assert "<th>${aliases[i]" in out_str
+
+        # no labels
+        m = self.world.explore(
+            tooltip=True,
+            popup=True,
+            tooltip_kwds=dict(labels=False),
+            popup_kwds=dict(labels=False),
+        )
+        out_str = self._fetch_map_string(m)
+        assert "<th>${aliases[i]" not in out_str
+
+        # named index
+        gdf = self.nybb.set_index("BoroName")
+        m = gdf.explore()
+        out_str = self._fetch_map_string(m)
+        assert "BoroName" in out_str
+
+    def test_default_markers(self):
+        # check overriden default for points
+        m = self.cities.explore()
+        strings = ['"radius":2', '"fill":true', "CircleMarker(latlng,opts)"]
+        out_str = self._fetch_map_string(m)
+        for s in strings:
+            assert s in out_str
+
+        m = self.cities.explore(marker_kwds=dict(radius=5, fill=False))
+        strings = ['"radius":5', '"fill":false', "CircleMarker(latlng,opts)"]
+        out_str = self._fetch_map_string(m)
+        for s in strings:
+            assert s in out_str
+
+    def test_custom_markers(self):
+        # Markers
+        m = self.cities.explore(
+            marker_type="marker",
+            marker_kwds={"icon": folium.Icon(icon="star")},
+        )
+        assert ""","icon":"star",""" in self._fetch_map_string(m)
+
+        # Circle Markers
+        m = self.cities.explore(marker_type="circle", marker_kwds={"fill_color": "red"})
+        assert ""","fillColor":"red",""" in self._fetch_map_string(m)
+
+        # Folium Markers
+        m = self.cities.explore(
+            marker_type=folium.Circle(
+                radius=4, fill_color="orange", fill_opacity=0.4, color="black", weight=1
+            ),
+        )
+        assert ""","color":"black",""" in self._fetch_map_string(m)
+
+        # Circle
+        m = self.cities.explore(marker_type="circle_marker", marker_kwds={"radius": 10})
+        assert ""","radius":10,""" in self._fetch_map_string(m)
+
+        # Unsupported Markers
+        with pytest.raises(
+            ValueError,
+            match="Only 'marker', 'circle', and 'circle_marker' are supported",
+        ):
+            self.cities.explore(marker_type="dummy")
+
+    def test_vmin_vmax(self):
+        df = self.world.copy()
+        df["range"] = range(len(df))
+        m = df.explore("range", vmin=-100, vmax=1000)
+        out_str = self._fetch_map_string(m)
+        assert 'case"176":return{"color":"#3b528b","fillColor":"#3b528b"' in out_str
+        assert 'case"119":return{"color":"#414287","fillColor":"#414287"' in out_str
+        assert 'case"3":return{"color":"#482173","fillColor":"#482173"' in out_str
+
+    def test_missing_vals(self):
+        m = self.missing.explore("continent")
+        assert '"fillColor":null' in self._fetch_map_string(m)
+
+        m = self.missing.explore("pop_est")
+        assert '"fillColor":null' in self._fetch_map_string(m)
+
+        m = self.missing.explore("pop_est", missing_kwds=dict(color="red"))
+        assert '"fillColor":"red"' in self._fetch_map_string(m)
+
+        m = self.missing.explore("continent", missing_kwds=dict(color="red"))
+        assert '"fillColor":"red"' in self._fetch_map_string(m)
+
+    def test_categorical_legend(self):
+        m = self.world.explore("continent", legend=True)
+        out_str = self._fetch_map_string(m)
+        assert "#1f77b4'></span>Africa" in out_str
+        assert "#ff7f0e'></span>Antarctica" in out_str
+        assert "#98df8a'></span>Asia" in out_str
+        assert "#9467bd'></span>Europe" in out_str
+        assert "#c49c94'></span>NorthAmerica" in out_str
+        assert "#7f7f7f'></span>Oceania" in out_str
+        assert "#dbdb8d'></span>Sevenseas(openocean)" in out_str
+        assert "#9edae5'></span>SouthAmerica" in out_str
+
+        m = self.missing.explore(
+            "continent", legend=True, missing_kwds={"color": "red"}
+        )
+        out_str = self._fetch_map_string(m)
+        assert "red'></span>NaN" in out_str
+
+    def test_colorbar(self):
+        m = self.world.explore("range", legend=True)
+        out_str = self._fetch_map_string(m)
+        assert "attr(\"id\",'legend')" in out_str
+        assert "text('range')" in out_str
+
+        m = self.world.explore(
+            "range", legend=True, legend_kwds=dict(caption="my_caption")
+        )
+        out_str = self._fetch_map_string(m)
+        assert "attr(\"id\",'legend')" in out_str
+        assert "text('my_caption')" in out_str
+
+        m = self.missing.explore("pop_est", legend=True, missing_kwds=dict(color="red"))
+        out_str = self._fetch_map_string(m)
+        assert "red'></span>NaN" in out_str
+
+        # do not scale legend
+        m = self.world.explore(
+            "pop_est",
+            legend=True,
+            legend_kwds=dict(scale=False),
+            scheme="Headtailbreaks",
+        )
+        out_str = self._fetch_map_string(m)
+        assert out_str.count("#440154ff") == 100
+        assert out_str.count("#3b528bff") == 100
+        assert out_str.count("#21918cff") == 100
+        assert out_str.count("#5ec962ff") == 100
+        assert out_str.count("#fde725ff") == 100
+
+        # scale legend accorrdingly
+        m = self.world.explore(
+            "pop_est",
+            legend=True,
+            scheme="Headtailbreaks",
+        )
+        out_str = self._fetch_map_string(m)
+        assert out_str.count("#440154ff") == 16
+        assert out_str.count("#3b528bff") == 51
+        assert out_str.count("#21918cff") == 133
+        assert out_str.count("#5ec962ff") == 282
+        assert out_str.count("#fde725ff") == 18
+
+        # discrete cmap
+        m = self.world.explore("pop_est", legend=True, cmap="Pastel2")
+        out_str = self._fetch_map_string(m)
+
+        assert out_str.count("b3e2cdff") == 63
+        assert out_str.count("fdcdacff") == 62
+        assert out_str.count("cbd5e8ff") == 63
+        assert out_str.count("f4cae4ff") == 62
+        assert out_str.count("e6f5c9ff") == 62
+        assert out_str.count("fff2aeff") == 63
+        assert out_str.count("f1e2ccff") == 62
+        assert out_str.count("ccccccff") == 63
+
+    @pytest.mark.skipif(not BRANCA_05, reason="requires branca >= 0.5.0")
+    def test_colorbar_max_labels(self):
+        # linear
+        m = self.world.explore("pop_est", legend_kwds=dict(max_labels=3))
+        out_str = self._fetch_map_string(m)
+
+        tick_values = [140.0, 465176713.5921569, 930353287.1843138]
+        for tick in tick_values:
+            assert str(tick) in out_str
+
+        # scheme
+        m = self.world.explore(
+            "pop_est", scheme="headtailbreaks", legend_kwds=dict(max_labels=3)
+        )
+        out_str = self._fetch_map_string(m)
+
+        assert "tickValues([140,'',182567501.0,'',1330619341.0,''])" in out_str
+
+        # short cmap
+        m = self.world.explore("pop_est", legend_kwds=dict(max_labels=3), cmap="tab10")
+        out_str = self._fetch_map_string(m)
+
+        tick_values = [140.0, 551721192.4, 1103442244.8]
+        for tick in tick_values:
+            assert str(tick) in out_str
+
+    def test_xyzservices_providers(self):
+        xyzservices = pytest.importorskip("xyzservices")
+
+        m = self.nybb.explore(tiles=xyzservices.providers.CartoDB.PositronNoLabels)
+        out_str = self._fetch_map_string(m)
+
+        assert (
+            '"https://a.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png"'
+            in out_str
+        )
+        assert (
+            'attribution":"\\u0026copy;\\u003cahref=\\"https://www.openstreetmap.org'
+            in out_str
+        )
+        assert '"maxNativeZoom":19,"maxZoom":19,"minZoom":0' in out_str
+
+    def test_xyzservices_query_name(self):
+        pytest.importorskip("xyzservices")
+
+        m = self.nybb.explore(tiles="CartoDB Positron No Labels")
+        out_str = self._fetch_map_string(m)
+
+        assert (
+            '"https://a.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png"'
+            in out_str
+        )
+        assert (
+            'attribution":"\\u0026copy;\\u003cahref=\\"https://www.openstreetmap.org'
+            in out_str
+        )
+        assert '"maxNativeZoom":19,"maxZoom":19,"minZoom":0' in out_str
+
+    def test_linearrings(self):
+        rings = self.nybb.explode().exterior
+        m = rings.explore()
+        out_str = self._fetch_map_string(m)
+
+        assert out_str.count("LineString") == len(rings)
+
+    def test_mapclassify_categorical_legend(self):
+        m = self.missing.explore(
+            column="pop_est",
+            legend=True,
+            scheme="naturalbreaks",
+            missing_kwds=dict(color="red", label="missing"),
+            legend_kwds=dict(colorbar=False, interval=True),
+        )
+        out_str = self._fetch_map_string(m)
+
+        strings = [
+            "[140.00,33986655.00]",
+            "(33986655.00,105350020.00]",
+            "(105350020.00,207353391.00]",
+            "(207353391.00,326625791.00]",
+            "(326625791.00,1379302771.00]",
+            "missing",
+        ]
+        for s in strings:
+            assert s in out_str
+
+        # interval=False
+        m = self.missing.explore(
+            column="pop_est",
+            legend=True,
+            scheme="naturalbreaks",
+            missing_kwds=dict(color="red", label="missing"),
+            legend_kwds=dict(colorbar=False, interval=False),
+        )
+        out_str = self._fetch_map_string(m)
+
+        strings = [
+            ">140.00,33986655.00",
+            ">33986655.00,105350020.00",
+            ">105350020.00,207353391.00",
+            ">207353391.00,326625791.00",
+            ">326625791.00,1379302771.00",
+            "missing",
+        ]
+        for s in strings:
+            assert s in out_str
+
+        # custom labels
+        m = self.world.explore(
+            column="pop_est",
+            legend=True,
+            scheme="naturalbreaks",
+            k=5,
+            legend_kwds=dict(colorbar=False, labels=["s", "m", "l", "xl", "xxl"]),
+        )
+        out_str = self._fetch_map_string(m)
+
+        strings = [">s<", ">m<", ">l<", ">xl<", ">xxl<"]
+        for s in strings:
+            assert s in out_str
+
+        # fmt
+        m = self.missing.explore(
+            column="pop_est",
+            legend=True,
+            scheme="naturalbreaks",
+            missing_kwds=dict(color="red", label="missing"),
+            legend_kwds=dict(colorbar=False, fmt="{:.0f}"),
+        )
+        out_str = self._fetch_map_string(m)
+
+        strings = [
+            ">140,33986655",
+            ">33986655,105350020",
+            ">105350020,207353391",
+            ">207353391,326625791",
+            ">326625791,1379302771",
+            "missing",
+        ]
+        for s in strings:
+            assert s in out_str
+
+    def test_given_m(self):
+        "Check that geometry is mapped onto a given folium.Map"
+        m = folium.Map()
+        self.nybb.explore(m=m, tooltip=False, highlight=False)
+
+        out_str = self._fetch_map_string(m)
+
+        assert out_str.count("BoroCode") == 5
+        # should not change map settings
+        assert m.options["zoom"] == 1
+
+    def test_highlight(self):
+        m = self.nybb.explore(highlight=True)
+        out_str = self._fetch_map_string(m)
+
+        assert '"fillOpacity":0.75' in out_str
+
+        m = self.nybb.explore(
+            highlight=True, highlight_kwds=dict(fillOpacity=1, color="red")
+        )
+        out_str = self._fetch_map_string(m)
+
+        assert '{"color":"red","fillOpacity":1}' in out_str
+
+    def test_custom_colormaps(self):
+
+        step = StepColormap(["green", "yellow", "red"], vmin=0, vmax=100000000)
+
+        m = self.world.explore("pop_est", cmap=step, tooltip=["name"], legend=True)
+
+        strings = [
+            'fillColor":"#008000ff"',  # Green
+            '"fillColor":"#ffff00ff"',  # Yellow
+            '"fillColor":"#ff0000ff"',  # Red
+        ]
+
+        out_str = self._fetch_map_string(m)
+        for s in strings:
+            assert s in out_str
+
+        assert out_str.count("008000ff") == 306
+        assert out_str.count("ffff00ff") == 187
+        assert out_str.count("ff0000ff") == 190
+
+        # Using custom function colormap
+        def my_color_function(field):
+            """Maps low values to green and high values to red."""
+            if field > 100000000:
+                return "#ff0000"
+            else:
+                return "#008000"
+
+        m = self.world.explore("pop_est", cmap=my_color_function, legend=False)
+
+        strings = [
+            '"color":"#ff0000","fillColor":"#ff0000"',
+            '"color":"#008000","fillColor":"#008000"',
+        ]
+
+        for s in strings:
+            assert s in self._fetch_map_string(m)
+
+        # matplotlib.Colormap
+        cmap = colors.ListedColormap(["red", "green", "blue", "white", "black"])
+
+        m = self.nybb.explore("BoroName", cmap=cmap)
+        strings = [
+            '"fillColor":"#ff0000"',  # Red
+            '"fillColor":"#008000"',  # Green
+            '"fillColor":"#0000ff"',  # Blue
+            '"fillColor":"#ffffff"',  # White
+            '"fillColor":"#000000"',  # Black
+        ]
+
+        out_str = self._fetch_map_string(m)
+        for s in strings:
+            assert s in out_str
+
+    def test_multiple_geoseries(self):
+        """
+        Additional GeoSeries need to be removed as they cannot be converted to GeoJSON
+        """
+        gdf = self.nybb
+        gdf["boundary"] = gdf.boundary
+        gdf["centroid"] = gdf.centroid
+
+        gdf.explore()
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 32673139..2db561d6 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -262,6 +262,42 @@ def test_astype_invalid_geodataframe():
     assert res["a"].dtype == object
 
 
+@pytest.mark.xfail(
+    not compat.PANDAS_GE_10,
+    reason="Convert dtypes new in pandas 1.0",
+    raises=NotImplementedError,
+)
+def test_convert_dtypes(df):
+    # https://github.com/geopandas/geopandas/issues/1870
+
+    # Test geometry col is first col, first, geom_col_name=geometry
+    # (order is important in concat, used internally)
+    res1 = df.convert_dtypes()  # note res1 done first for pandas < 1 xfail check
+
+    expected1 = GeoDataFrame(
+        pd.DataFrame(df).convert_dtypes(), crs=df.crs, geometry=df.geometry.name
+    )
+
+    # Checking type and metadata are right
+    assert_geodataframe_equal(expected1, res1)
+
+    # Test geom last, geom_col_name=geometry
+    res2 = df[["value1", "value2", "geometry"]].convert_dtypes()
+    assert_geodataframe_equal(expected1[["value1", "value2", "geometry"]], res2)
+
+    # Test again with crs set and custom geom col name
+    df2 = df.set_crs(epsg=4326).rename_geometry("points")
+    expected2 = GeoDataFrame(
+        pd.DataFrame(df2).convert_dtypes(), crs=df2.crs, geometry=df2.geometry.name
+    )
+    res3 = df2.convert_dtypes()
+    assert_geodataframe_equal(expected2, res3)
+
+    # Test geom last, geom_col=geometry
+    res4 = df2[["value1", "value2", "points"]].convert_dtypes()
+    assert_geodataframe_equal(expected2[["value1", "value2", "points"]], res4)
+
+
 def test_to_csv(df):
 
     exp = (