Repository: geopandas/geopandas

Issue:
BUG: explore() with all-empty geometries
All empty points:

```python
import geopandas
from shapely import Point

gdf = geopandas.GeoDataFrame(geometry=[Point(), Point()])
gdf.explore()
```

gives
```
File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/geopandas/geodataframe.py:2205](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/geopandas/geodataframe.py#line=2204), in GeoDataFrame.explore(self, *args, **kwargs)
   2203 @doc(_explore)
   2204 def explore(self, *args, **kwargs):
-> 2205     return _explore(self, *args, **kwargs)

File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/geopandas/explore.py:383](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/geopandas/explore.py#line=382), in _explore(df, column, cmap, color, m, tiles, attr, tooltip, popup, highlight, categorical, legend, scheme, k, vmin, vmax, width, height, categories, classification_kwds, control_scale, marker_type, marker_kwds, style_kwds, highlight_kwds, missing_kwds, tooltip_kwds, popup_kwds, legend_kwds, map_kwds, **kwargs)
    380             map_kwds["max_zoom"] = tiles.get("max_zoom", 18)
    381         tiles = tiles.build_url(scale_factor="{r}")
--> 383 m = folium.Map(
    384     location=location,
    385     control_scale=control_scale,
    386     tiles=tiles,
    387     attr=attr,
    388     width=width,
    389     height=height,
    390     **map_kwds,
    391 )
    393 # fit bounds to get a proper zoom level
    394 if fit:

File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/folium.py:274](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/folium.py#line=273), in Map.__init__(self, location, width, height, left, top, position, tiles, attr, min_zoom, max_zoom, zoom_start, min_lat, max_lat, min_lon, max_lon, max_bounds, crs, control_scale, prefer_canvas, no_touch, disable_3d, png_enabled, zoom_control, font_size, **kwargs)
    272     zoom_start = 1
    273 else:
--> 274     self.location = validate_location(location)
    276 Figure().add_child(self)
    278 # Map Size Parameters.

File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/utilities.py:101](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/utilities.py#line=100), in validate_location(location)
     96         raise ValueError(
     97             "Location should consist of two numerical values, "
     98             f"but {coord!r} of type {type(coord)} is not convertible to float."
     99         )
    100     if math.isnan(float(coord)):
--> 101         raise ValueError("Location values cannot contain NaNs.")
    102 return [float(x) for x in coords]

ValueError: Location values cannot contain NaNs.
```

And then specifically when adding the empties to an existing map and with a named index level, there is another obscure error:

```python
gdf1 = geopandas.GeoDataFrame(geometry=[Point(0, 0), Point(1, 1)])
gdf2 = geopandas.GeoDataFrame(geometry=[Point(), Point()])

m = gdf1.rename_axis(index="index_name").explore()
gdf2.rename_axis(index="index_name").explore(m=m, color="red")
```

gives

```
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/IPython/core/formatters.py:347](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/IPython/core/formatters.py#line=346), in BaseFormatter.__call__(self, obj)
    345     method = get_real_method(obj, self.print_method)
    346     if method is not None:
--> 347         return method()
    348     return None
    349 else:

File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/folium.py:321](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/folium.py#line=320), in Map._repr_html_(self, **kwargs)
    319     self._parent = None
    320 else:
--> 321     out = self._parent._repr_html_(**kwargs)
    322 return out

File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/branca/element.py:369](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/branca/element.py#line=368), in Figure._repr_html_(self, **kwargs)
    367 def _repr_html_(self, **kwargs):
    368     """Displays the Figure in a Jupyter notebook."""
--> 369     html = escape(self.render(**kwargs))
    370     if self.height is None:
    371         iframe = (
    372             '<div style="width:{width};">'
    373             '<div style="position:relative;width:100%;height:0;padding-bottom:{ratio};">'  # noqa
   (...)
    379             "<[/div](http://localhost:8888/div)><[/div](http://localhost:8888/div)>"
    380         ).format(html=html, width=self.width, ratio=self.ratio)

File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/branca/element.py:364](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/branca/element.py#line=363), in Figure.render(self, **kwargs)
    362 """Renders the HTML representation of the element."""
    363 for name, child in self._children.items():
--> 364     child.render(**kwargs)
    365 return self._template.render(this=self, kwargs=kwargs)

File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/folium.py:403](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/folium.py#line=402), in Map.render(self, **kwargs)
    376 figure.header.add_child(
    377     Element(
    378         "<style>html, body {"
   (...)
    386     name="css_style",
    387 )
    389 figure.header.add_child(
    390     Element(
    391         "<style>#map {"
   (...)
    400     name="map_style",
    401 )
--> 403 super().render(**kwargs)

File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/elements.py:25](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/elements.py#line=24), in JSCSSMixin.render(self, **kwargs)
     22 for name, url in self.default_css:
     23     figure.header.add_child(CssLink(url), name=name)
---> 25 super().render(**kwargs)

File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/branca/element.py:681](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/branca/element.py#line=680), in MacroElement.render(self, **kwargs)
    678     figure.script.add_child(Element(script(self, kwargs)), name=self.get_name())
    680 for name, element in self._children.items():
--> 681     element.render(**kwargs)

File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/features.py:831](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/features.py#line=830), in GeoJson.render(self, **kwargs)
    829     if self.highlight:
    830         self.highlight_map = mapper.get_highlight_map(self.highlight_function)
--> 831 super().render()

File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/map.py:63](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/map.py#line=62), in Layer.render(self, **kwargs)
     55 if self.show:
     56     self.add_child(
     57         ElementAddToElement(
     58             element_name=self.get_name(),
   (...)
     61         name=self.get_name() + "_add",
     62     )
---> 63 super().render(**kwargs)

File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/branca/element.py:681](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/branca/element.py#line=680), in MacroElement.render(self, **kwargs)
    678     figure.script.add_child(Element(script(self, kwargs)), name=self.get_name())
    680 for name, element in self._children.items():
--> 681     element.render(**kwargs)

File [~/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/features.py:1189](http://localhost:8888/home/joris/conda/envs/geopandas-workshop/lib/python3.12/site-packages/folium/features.py#line=1188), in GeoJsonDetail.render(self, **kwargs)
   1186 keys = tuple(x for x in keys if x not in ("style", "highlight"))
   1187 for value in self.fields:
   1188     assert (
-> 1189         value in keys
   1190     ), f"The field {value} is not available in the data. Choose from: {keys}."
   1191 figure.header.add_child(
   1192     Element(
   1193         Template(
   (...)
   1212     name=self.get_name() + "tablestyle",
   1213 )
   1215 super().render()

AssertionError: The field index_name is not available in the data. Choose from: ().
```


Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d725c97c..7316809e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,9 @@ New methods:
 - Added `polygonize` method exposing both `polygonize` and `polygonize_full` from
   shapely to GeoSeries/GeoDataframe (#2963).
 - Added `is_valid_reason` method from shapely to GeoSeries/GeoDataframe (#3176).
+- Added `to_arrow` method and `from_arrow` class method to
+  GeoSeries/GeoDataFrame to export and import to/from Arrow data with GeoArrow
+  extension types (#3219, #3301).
 
 New features and improvements:
 
@@ -147,6 +150,7 @@ Bug fixes:
 - Fix `to_parquet` to write correct metadata in case of 3D geometries (#2824).
 - Fixes for compatibility with psycopg (#3167).
 - Fix to allow appending dataframes with no CRS to PostGIS tables with no CRS (#3328)
+- Fix plotting of all-empty GeoSeries using `explore` (#3316).
 
 ## Version 0.14.4 (April 26, 2024)
 
diff --git a/geopandas/explore.py b/geopandas/explore.py
index 899be57d..bc14fb53 100644
--- a/geopandas/explore.py
+++ b/geopandas/explore.py
@@ -1,3 +1,4 @@
+import warnings
 from packaging.version import Version
 from statistics import mean
 
@@ -341,7 +342,7 @@ def _explore(
         # Get bounds to specify location and map extent
         bounds = gdf.total_bounds
         location = kwargs.pop("location", None)
-        if location is None:
+        if location is None and not np.isnan(bounds).all():
             x = mean([bounds[0], bounds[2]])
             y = mean([bounds[1], bounds[3]])
             location = (y, x)
@@ -394,6 +395,15 @@ def _explore(
         if fit:
             m.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]])
 
+    if gdf.is_empty.all():
+        warnings.warn(
+            "The GeoSeries you are attempting to plot is "
+            "composed of empty geometries. Nothing has been displayed.",
+            UserWarning,
+            stacklevel=3,
+        )
+        return m
+
     for map_kwd in _MAP_KWARGS:
         kwargs.pop(map_kwd, None)
 
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 19e7f2ee..272855c8 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -816,6 +816,42 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
 
         return df
 
+    @classmethod
+    def from_arrow(cls, table, geometry=None):
+        """
+        Construct a GeoDataFrame from a Arrow table object based on GeoArrow
+        extension types.
+
+        See https://geoarrow.org/ for details on the GeoArrow specification.
+
+        This functions accepts any tabular Arrow object implementing
+        the `Arrow PyCapsule Protocol`_ (i.e. having an ``__arrow_c_array__``
+        or ``__arrow_c_stream__`` method).
+
+        .. _Arrow PyCapsule Protocol: https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html
+
+        .. versionadded:: 1.0
+
+        Parameters
+        ----------
+        table : pyarrow.Table or Arrow-compatible table
+            Any tabular object implementing the Arrow PyCapsule Protocol
+            (i.e. has an ``__arrow_c_array__`` or ``__arrow_c_stream__``
+            method). This table should have at least one column with a
+            geoarrow geometry type.
+        geometry : str, default None
+            The name of the geometry column to set as the active geometry
+            column. If None, the first geometry column found will be used.
+
+        Returns
+        -------
+        GeoDataFrame
+
+        """
+        from geopandas.io._geoarrow import arrow_to_geopandas
+
+        return arrow_to_geopandas(table, geometry=geometry)
+
     def to_json(
         self, na="null", show_bbox=False, drop_id=False, to_wgs84=False, **kwargs
     ):
@@ -1222,7 +1258,7 @@ properties': {'col1': 'name1'}, 'geometry': {'type': 'Point', 'coordinates': (1.
 
         >>> arrow_table = gdf.to_arrow()
         >>> arrow_table
-        <geopandas.io.geoarrow.ArrowTable object at ...>
+        <geopandas.io._geoarrow.ArrowTable object at ...>
 
         The returned data object needs to be consumed by a library implementing
         the Arrow PyCapsule Protocol. For example, wrapping the data as a
@@ -1240,7 +1276,7 @@ properties': {'col1': 'name1'}, 'geometry': {'type': 'Point', 'coordinates': (1.
 01010000000000000000000040000000000000F03F]]
 
         """
-        from geopandas.io.geoarrow import ArrowTable, geopandas_to_arrow
+        from geopandas.io._geoarrow import ArrowTable, geopandas_to_arrow
 
         table, _ = geopandas_to_arrow(
             self,
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 505304e7..ea30f61d 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -556,6 +556,41 @@ class GeoSeries(GeoPandasBase, Series):
             **kwargs,
         )
 
+    @classmethod
+    def from_arrow(cls, arr, **kwargs) -> GeoSeries:
+        """
+        Construct a GeoSeries from a Arrow array object with a GeoArrow
+        extension type.
+
+        See https://geoarrow.org/ for details on the GeoArrow specification.
+
+        This functions accepts any Arrow array object implementing
+        the `Arrow PyCapsule Protocol`_ (i.e. having an ``__arrow_c_array__``
+        method).
+
+        .. _Arrow PyCapsule Protocol: https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html
+
+        .. versionadded:: 1.0
+
+        Parameters
+        ----------
+        arr : pyarrow.Array, Arrow array
+            Any array object implementing the Arrow PyCapsule Protocol
+            (i.e. has an ``__arrow_c_array__`` or ``__arrow_c_stream__``
+            method). The type of the array should be one of the
+            geoarrow geometry types.
+        **kwargs
+            Other parameters passed to the GeoSeries constructor.
+
+        Returns
+        -------
+        GeoSeries
+
+        """
+        from geopandas.io._geoarrow import arrow_to_geometry_array
+
+        return cls(arrow_to_geometry_array(arr), **kwargs)
+
     @property
     def __geo_interface__(self) -> Dict:
         """Returns a ``GeoSeries`` as a python feature collection.
@@ -1376,7 +1411,7 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
 
         >>> arrow_array = gser.to_arrow()
         >>> arrow_array
-        <geopandas.io.geoarrow.GeoArrowArray object at ...>
+        <geopandas.io._geoarrow.GeoArrowArray object at ...>
 
         The returned array object needs to be consumed by a library implementing
         the Arrow PyCapsule Protocol. For example, wrapping the data as a
@@ -1394,7 +1429,7 @@ e": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [3
         """
         import pyarrow as pa
 
-        from geopandas.io.geoarrow import (
+        from geopandas.io._geoarrow import (
             GeoArrowArray,
             construct_geometry_array,
             construct_wkb_array,
diff --git a/geopandas/io/geoarrow.py b/geopandas/io/_geoarrow.py
similarity index 81%
rename from geopandas/io/geoarrow.py
rename to geopandas/io/_geoarrow.py
index 004152ca..32ccf519 100644
--- a/geopandas/io/geoarrow.py
+++ b/geopandas/io/_geoarrow.py
@@ -10,7 +10,21 @@ from numpy.typing import NDArray
 import shapely
 from shapely import GeometryType
 
+from geopandas import GeoDataFrame
 from geopandas._compat import SHAPELY_GE_204
+from geopandas.array import from_shapely, from_wkb
+
+GEOARROW_ENCODINGS = [
+    "point",
+    "linestring",
+    "polygon",
+    "multipoint",
+    "multilinestring",
+    "multipolygon",
+]
+
+
+## GeoPandas -> GeoArrow
 
 
 class ArrowTable:
@@ -407,6 +421,119 @@ def construct_geometry_array(
         raise ValueError(f"Unsupported type for geoarrow: {geom_type}")
 
 
+## GeoArrow -> GeoPandas
+
+
+def _get_arrow_geometry_field(field):
+    if (meta := field.metadata) is not None:
+        if (ext_name := meta.get(b"ARROW:extension:name", None)) is not None:
+            if ext_name.startswith(b"geoarrow."):
+                if (
+                    ext_meta := meta.get(b"ARROW:extension:metadata", None)
+                ) is not None:
+                    ext_meta = json.loads(ext_meta.decode())
+                return ext_name.decode(), ext_meta
+
+    if isinstance(field.type, pa.ExtensionType):
+        ext_name = field.type.extension_name
+        if ext_name.startswith("geoarrow."):
+            ext_meta_ser = field.type.__arrow_ext_serialize__()
+            if ext_meta_ser:
+                ext_meta = json.loads(ext_meta_ser.decode())
+            else:
+                ext_meta = None
+            return ext_name, ext_meta
+
+    return None
+
+
+def arrow_to_geopandas(table, geometry=None):
+    """
+    Convert Arrow table object to a GeoDataFrame based on GeoArrow extension types.
+
+    Parameters
+    ----------
+    table : pyarrow.Table
+        The Arrow table to convert.
+    geometry : str, default None
+        The name of the geometry column to set as the active geometry
+        column. If None, the first geometry column found will be used.
+
+    Returns
+    -------
+    GeoDataFrame
+
+    """
+    if not isinstance(table, pa.Table):
+        table = pa.table(table)
+
+    geom_fields = []
+
+    for i, field in enumerate(table.schema):
+        geom = _get_arrow_geometry_field(field)
+        if geom is not None:
+            geom_fields.append((i, field.name, *geom))
+
+    if len(geom_fields) == 0:
+        raise ValueError("No geometry column found in the Arrow table.")
+
+    table_attr = table.drop([f[1] for f in geom_fields])
+    df = table_attr.to_pandas()
+
+    for i, col, ext_name, ext_meta in geom_fields:
+        crs = None
+        if ext_meta is not None and "crs" in ext_meta:
+            crs = ext_meta["crs"]
+
+        if ext_name == "geoarrow.wkb":
+            geom_arr = from_wkb(np.array(table[col]), crs=crs)
+        elif ext_name.split(".")[1] in GEOARROW_ENCODINGS:
+
+            geom_arr = from_shapely(
+                construct_shapely_array(table[col].combine_chunks(), ext_name), crs=crs
+            )
+        else:
+            raise TypeError(f"Unknown GeoArrow extension type: {ext_name}")
+
+        df.insert(i, col, geom_arr)
+
+    return GeoDataFrame(df, geometry=geometry or geom_fields[0][1])
+
+
+def arrow_to_geometry_array(arr):
+    """
+    Convert Arrow array object (representing single GeoArrow array) to a
+    geopandas GeometryArray.
+
+    Specifically for GeoSeries.from_arrow.
+    """
+    if Version(pa.__version__) < Version("14.0.0"):
+        raise ValueError("Importing from Arrow requires pyarrow >= 14.0.")
+
+    schema_capsule, array_capsule = arr.__arrow_c_array__()
+    field = pa.Field._import_from_c_capsule(schema_capsule)
+    pa_arr = pa.Array._import_from_c_capsule(field.__arrow_c_schema__(), array_capsule)
+
+    geom_info = _get_arrow_geometry_field(field)
+    if geom_info is None:
+        raise ValueError("No GeoArrow geometry field found.")
+    ext_name, ext_meta = geom_info
+
+    crs = None
+    if ext_meta is not None and "crs" in ext_meta:
+        crs = ext_meta["crs"]
+
+    if ext_name == "geoarrow.wkb":
+        geom_arr = from_wkb(np.array(pa_arr), crs=crs)
+    elif ext_name.split(".")[1] in GEOARROW_ENCODINGS:
+
+        geom_arr = from_shapely(construct_shapely_array(pa_arr, ext_name), crs=crs)
+    else:
+        raise ValueError(f"Unknown GeoArrow extension type: {ext_name}")
+
+    return geom_arr
+
+
 def _get_inner_coords(arr):
     if pa.types.is_struct(arr.type):
         if arr.type.num_fields == 2:
@@ -433,6 +560,9 @@ def construct_shapely_array(arr: pa.Array, extension_name: str):
     with GeoArrow extension type.
 
     """
+    if isinstance(arr, pa.ExtensionArray):
+        arr = arr.storage
+
     if extension_name == "geoarrow.point":
         coords = _get_inner_coords(arr)
         result = shapely.from_ragged_array(GeometryType.POINT, coords, None)
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
index 26336fde..4becc18e 100644
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -335,7 +335,7 @@ def _geopandas_to_arrow(
     """
     from pyarrow import StructArray
 
-    from geopandas.io.geoarrow import geopandas_to_arrow
+    from geopandas.io._geoarrow import geopandas_to_arrow
 
     _validate_dataframe(df)
 
@@ -563,7 +563,7 @@ def _arrow_to_geopandas(table, geo_metadata=None):
         if col_metadata["encoding"] == "WKB":
             geom_arr = from_wkb(np.array(table[col]), crs=crs)
         else:
-            from geopandas.io.geoarrow import construct_shapely_array
+            from geopandas.io._geoarrow import construct_shapely_array
 
             geom_arr = from_shapely(
                 construct_shapely_array(


Test patch:
diff --git a/geopandas/io/tests/test_geoarrow.py b/geopandas/io/tests/test_geoarrow.py
index c9502264..0087e6b0 100644
--- a/geopandas/io/tests/test_geoarrow.py
+++ b/geopandas/io/tests/test_geoarrow.py
@@ -12,6 +12,7 @@ from shapely import MultiPoint, Point, box
 from geopandas import GeoDataFrame, GeoSeries
 
 import pytest
+from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal
 
 pytest.importorskip("pyarrow")
 import pyarrow as pa
@@ -199,6 +200,10 @@ def test_geoarrow_export(geometry_type, dim, geometry_encoding, interleaved):
     assert result_arr.equals(expected["geometry"].chunk(0))
 
 
+@pytest.mark.skipif(
+    Version(shapely.__version__) < Version("2.0.2"),
+    reason="from_ragged_array failing with read-only array input",
+)
 @pytest.mark.parametrize("encoding", ["WKB", "geoarrow"])
 def test_geoarrow_multiple_geometry_crs(encoding):
     pytest.importorskip("pyproj")
@@ -216,6 +221,11 @@ def test_geoarrow_multiple_geometry_crs(encoding):
     )
     assert json.loads(meta2["crs"])["id"]["code"] == 3857
 
+    roundtripped = GeoDataFrame.from_arrow(result)
+    assert_geodataframe_equal(gdf, roundtripped)
+    assert gdf.geometry.crs == "epsg:4326"
+    assert gdf.geom2.crs == "epsg:3857"
+
 
 @pytest.mark.parametrize("encoding", ["WKB", "geoarrow"])
 def test_geoarrow_series_name_crs(encoding):
@@ -361,3 +371,167 @@ def test_geoarrow_export_with_extension_types(geometry_type, dim):
 
         result3 = pa_table(df.to_arrow(geometry_encoding="geoarrow", interleaved=False))
         assert isinstance(result3["geometry"].type, pa.ExtensionType)
+
+
+@pytest.mark.skipif(
+    Version(shapely.__version__) < Version("2.0.2"),
+    reason="from_ragged_array failing with read-only array input",
+)
+@pytest.mark.parametrize("dim", ["xy", "xyz"])
+@pytest.mark.parametrize(
+    "geometry_type",
+    [
+        "point",
+        "linestring",
+        "polygon",
+        "multipoint",
+        "multilinestring",
+        "multipolygon",
+    ],
+)
+def test_geoarrow_import(geometry_type, dim):
+    base_path = DATA_PATH / "geoarrow"
+    suffix = geometry_type + ("_z" if dim == "xyz" else "")
+
+    # Read the example data
+    df = feather.read_feather(base_path / f"example-{suffix}-wkb.arrow")
+    df["geometry"] = GeoSeries.from_wkb(df["geometry"])
+    df = GeoDataFrame(df)
+    df.geometry.crs = None
+
+    table1 = feather.read_table(base_path / f"example-{suffix}-wkb.arrow")
+    result1 = GeoDataFrame.from_arrow(table1)
+    assert_geodataframe_equal(result1, df)
+
+    table2 = feather.read_table(base_path / f"example-{suffix}-interleaved.arrow")
+    result2 = GeoDataFrame.from_arrow(table2)
+    assert_geodataframe_equal(result2, df)
+
+    table3 = feather.read_table(base_path / f"example-{suffix}.arrow")
+    result3 = GeoDataFrame.from_arrow(table3)
+    assert_geodataframe_equal(result3, df)
+
+
+@pytest.mark.skipif(
+    Version(shapely.__version__) < Version("2.0.2"),
+    reason="from_ragged_array failing with read-only array input",
+)
+@pytest.mark.parametrize("encoding", ["WKB", "geoarrow"])
+def test_geoarrow_import_geometry_column(encoding):
+    pytest.importorskip("pyproj")
+    # ensure each geometry column has its own crs
+    gdf = GeoDataFrame(geometry=[box(0, 0, 10, 10)])
+    gdf["centroid"] = gdf.geometry.centroid
+
+    result = GeoDataFrame.from_arrow(pa_table(gdf.to_arrow(geometry_encoding=encoding)))
+    assert_geodataframe_equal(result, gdf)
+    assert result.active_geometry_name == "geometry"
+
+    result = GeoDataFrame.from_arrow(
+        pa_table(gdf[["centroid"]].to_arrow(geometry_encoding=encoding))
+    )
+    assert result.active_geometry_name == "centroid"
+
+    result = GeoDataFrame.from_arrow(
+        pa_table(gdf.to_arrow(geometry_encoding=encoding)), geometry="centroid"
+    )
+    assert result.active_geometry_name == "centroid"
+    assert_geodataframe_equal(result, gdf.set_geometry("centroid"))
+
+
+def test_geoarrow_import_missing_geometry():
+    pytest.importorskip("pyarrow", minversion="14.0.0")
+
+    table = pa.table({"a": [0, 1, 2], "b": [0.1, 0.2, 0.3]})
+    with pytest.raises(ValueError, match="No geometry column found"):
+        GeoDataFrame.from_arrow(table)
+
+    with pytest.raises(ValueError, match="No GeoArrow geometry field found"):
+        GeoSeries.from_arrow(table["a"].chunk(0))
+
+
+def test_geoarrow_import_capsule_interface():
+    # ensure we can import non-pyarrow object
+    pytest.importorskip("pyarrow", minversion="14.0.0")
+    gdf = GeoDataFrame({"col": [1]}, geometry=[box(0, 0, 10, 10)])
+
+    result = GeoDataFrame.from_arrow(gdf.to_arrow())
+    assert_geodataframe_equal(result, gdf)
+
+
+@pytest.mark.parametrize("dim", ["xy", "xyz"])
+@pytest.mark.parametrize(
+    "geometry_type",
+    ["point", "linestring", "polygon", "multipoint", "multilinestring", "multipolygon"],
+)
+def test_geoarrow_import_from_extension_types(geometry_type, dim):
+    # ensure the exported data can be imported by geoarrow-pyarrow and are
+    # recognized as extension types
+    pytest.importorskip("pyproj")
+    base_path = DATA_PATH / "geoarrow"
+    suffix = geometry_type + ("_z" if dim == "xyz" else "")
+
+    # Read the example data
+    df = feather.read_feather(base_path / f"example-{suffix}-wkb.arrow")
+    df["geometry"] = GeoSeries.from_wkb(df["geometry"])
+    df = GeoDataFrame(df, crs="EPSG:3857")
+
+    pytest.importorskip("geoarrow.pyarrow")
+
+    with with_geoarrow_extension_types():
+        result1 = GeoDataFrame.from_arrow(
+            pa_table(df.to_arrow(geometry_encoding="WKB"))
+        )
+        assert_geodataframe_equal(result1, df)
+
+        result2 = GeoDataFrame.from_arrow(
+            pa_table(df.to_arrow(geometry_encoding="geoarrow"))
+        )
+        assert_geodataframe_equal(result2, df)
+
+        result3 = GeoDataFrame.from_arrow(
+            pa_table(df.to_arrow(geometry_encoding="geoarrow", interleaved=False))
+        )
+        assert_geodataframe_equal(result3, df)
+
+
+def test_geoarrow_import_geoseries():
+    pytest.importorskip("pyproj")
+    gp = pytest.importorskip("geoarrow.pyarrow")
+    ser = GeoSeries.from_wkt(["POINT (1 1)", "POINT (2 2)"], crs="EPSG:3857")
+
+    with with_geoarrow_extension_types():
+        arr = gp.array(ser.to_arrow(geometry_encoding="WKB"))
+        result = GeoSeries.from_arrow(arr)
+        assert_geoseries_equal(result, ser)
+
+        arr = gp.array(ser.to_arrow(geometry_encoding="geoarrow"))
+        result = GeoSeries.from_arrow(arr)
+        assert_geoseries_equal(result, ser)
+
+        # the name is lost when going through a pyarrow.Array
+        ser.name = "name"
+        arr = gp.array(ser.to_arrow())
+        result = GeoSeries.from_arrow(arr)
+        assert result.name is None
+        # we can specify the name as one of the kwargs
+        result = GeoSeries.from_arrow(arr, name="test")
+        assert_geoseries_equal(result, ser)
+
+
+def test_geoarrow_import_unknown_geoarrow_type():
+    gdf = GeoDataFrame({"col": [1]}, geometry=[box(0, 0, 10, 10)])
+    table = pa_table(gdf.to_arrow())
+    schema = table.schema
+    new_field = schema.field("geometry").with_metadata(
+        {
+            b"ARROW:extension:name": b"geoarrow.unknown",
+            b"ARROW:extension:metadata": b"{}",
+        }
+    )
+
+    new_schema = pa.schema([schema.field(0), new_field])
+    new_table = table.cast(new_schema)
+
+    with pytest.raises(TypeError, match="Unknown GeoArrow extension type"):
+        GeoDataFrame.from_arrow(new_table)
diff --git a/geopandas/tests/test_explore.py b/geopandas/tests/test_explore.py
index f1b15d65..d2f161cc 100644
--- a/geopandas/tests/test_explore.py
+++ b/geopandas/tests/test_explore.py
@@ -1004,3 +1004,37 @@ class TestExplore:
         df.loc[0, df.geometry.name] = shapely.Point()
         m = df.explore()
         self._fetch_map_string(m)
+
+    def test_all_empty(self):
+        with_crs = gpd.GeoDataFrame(
+            geometry=[shapely.Point(), shapely.Point()], crs=4326
+        )
+        with pytest.warns(
+            UserWarning,
+            match="The GeoSeries you are attempting to plot is composed of empty",
+        ):
+            m = with_crs.explore()
+        out_str = self._fetch_map_string(m)
+        if HAS_PYPROJ:
+            assert "center:[0.0,0.0],crs:L.CRS.EPSG3857" in out_str
+
+        no_crs = gpd.GeoDataFrame(geometry=[shapely.Point(), shapely.Point()])
+        with pytest.warns(
+            UserWarning,
+            match="The GeoSeries you are attempting to plot is composed of empty",
+        ):
+            m = no_crs.explore()
+        out_str = self._fetch_map_string(m)
+        assert "center:[0.0,0.0],crs:L.CRS.Simple" in out_str
+
+    def test_add_all_empty_named_index(self):
+        gdf1 = gpd.GeoDataFrame(geometry=[shapely.Point(0, 0), shapely.Point(1, 1)])
+        gdf2 = gpd.GeoDataFrame(geometry=[shapely.Point(), shapely.Point()])
+        m = gdf1.rename_axis(index="index_name").explore()
+        with pytest.warns(
+            UserWarning,
+            match="The GeoSeries you are attempting to plot is composed of empty",
+        ):
+            m = gdf2.rename_axis(index="index_name").explore(m=m, color="red")
+        out_str = self._fetch_map_string(m)
+        assert "center:[0.5,0.5],crs:L.CRS.Simple" in out_str