Repository: geopandas/geopandas
Pull request #3215 (2024-03-07 14:29:08)

ENH: add shared_paths method
xref #2010 

One thing I was considering here was if to explode the collection to two GeoSeries but if a user does not care about the direction then the original GeometryCollection that comes from GEOS is probably better and they can explode it later if needed.

jorisvandenbossche: Small idea for the docstring: should we mention how you can get the element out of the collection? For example, how to get a GeoSeries of multilinstrings of the same direction?
With shapely this is `shapely.get_geometry(geoms, 0)`, but not sure if we already expose that in geopandas as well? (I don't see it listed in https://github.com/geopandas/geopandas/issues/2010)

martinfleis: > With shapely this is shapely.get_geometry(geoms, 0), but not sure if we already expose that in geopandas as well? (I don't see it listed in https://github.com/geopandas/geopandas/issues/2010)

That must have fallen through. I will add it to the list and prepare a PR soon-ish. Will link to the resulting method from here once ready.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9ccb39b3..f1c5409b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,7 @@ New methods:
 - Added `set_precision` and `get_precision` methods from shapely to GeoSeries/GeoDataframe (#3175).
 - Added `count_coordinates` method from shapely to GeoSeries/GeoDataframe (#3026).
 - Added `minimum_clearance` method from shapely to GeoSeries/GeoDataframe (#2989).
+- Added `shared_paths` method from shapely to GeoSeries/GeoDataframe (#3215).
 - Added `is_ccw` method from shapely to GeoSeries/GeoDataframe (#3027).
 - Added `is_closed` attribute from shapely to GeoSeries/GeoDataframe (#3092).
 - Added `force_2d` and `force_3d` methods from shapely to GeoSeries/GeoDataframe (#3090).
@@ -66,6 +67,8 @@ New features and improvements:
   preservation of the original order of observations. (#3233)
 - Added `show_bbox`, `drop_id` and `to_wgs84` arguments to allow further customization of
   `GeoSeries.to_json` (#3226)
+- `explore` now supports `GeoDataFrame`s with additional columns containing datetimes, uuids and 
+  other non JSON serializable objects (#3261).
 - The `GeoSeries.fillna` method now supports the `limit` keyword (#3290).
 
 Backwards incompatible API changes:
@@ -77,6 +80,10 @@ Backwards incompatible API changes:
   the previous active geometry column name. This means that if the new and old names are
   different, then both columns will be preserved in the GeoDataFrame. To replicate the previous
   behaviour, you can instead call `gdf.set_geometry(ser.rename(gdf.active_geometry_name))` (#3237).
+- `delaunay_triangles` now considers all geometries together when creating the Delaunay trianguation
+  instead of performing the operation element-wise. If you want to generate Delaunay
+  triangles for each geometry separately, use ``shapely.delaunay_triangles`` instead. (#3273)
+
 
 Potentially breaking changes:
 
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index e09388ad..f09971eb 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -147,6 +147,7 @@ Linestring operations
    GeoSeries.interpolate
    GeoSeries.line_merge
    GeoSeries.project
+   GeoSeries.shared_paths
 
 Aggregating and exploding
 -------------------------
diff --git a/geopandas/array.py b/geopandas/array.py
index 7b379763..e9754d5f 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -574,12 +574,6 @@ class GeometryArray(ExtensionArray):
     def convex_hull(self):
         return GeometryArray(shapely.convex_hull(self._data), crs=self.crs)
 
-    def delaunay_triangles(self, tolerance, only_edges):
-        return GeometryArray(
-            shapely.delaunay_triangles(self._data, tolerance, only_edges),
-            crs=self.crs,
-        )
-
     @property
     def envelope(self):
         return GeometryArray(shapely.envelope(self._data), crs=self.crs)
@@ -790,6 +784,11 @@ class GeometryArray(ExtensionArray):
             self._binary_method("snap", self, other, tolerance=tolerance), crs=self.crs
         )
 
+    def shared_paths(self, other):
+        return GeometryArray(
+            self._binary_method("shared_paths", self, other), crs=self.crs
+        )
+
     #
     # Other operations
     #
diff --git a/geopandas/base.py b/geopandas/base.py
index 3b2bcd22..cba1c669 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -997,59 +997,103 @@ GeometryCollection
 
     def delaunay_triangles(self, tolerance=0.0, only_edges=False):
         """Returns a ``GeoSeries`` consisting of objects representing
-        the computed Delaunay triangulation around the vertices of
+        the computed Delaunay triangulation between the vertices of
         an input geometry.
 
-        The output is a ``GeometryCollection`` containing polygons
-        (default) or linestrings (see only_edges).
+        All geometries within the GeoSeries are considered together within a single
+        Delaunay triangulation. The resulting geometries therefore do not map 1:1
+        to input geometries. Note that each vertex of a geometry is considered a site
+        for the triangulation, so the triangles will be constructed between the vertices
+        of each geometry.
 
-        Returns an empty GeometryCollection if an input geometry
-        contains less than 3 vertices.
+        Notes
+        -----
+        If you want to generate Delaunay triangles for each geometry separately, use
+        :func:`shapely.delaunay_triangles` instead.
 
         Parameters
         ----------
-        tolerance : float | array-like, default 0.0
+        tolerance : float, default 0.0
             Snap input vertices together if their distance is less than this value.
-        only_edges : bool | array_like, (optional, default False)
-            If set to True, the triangulation will return a collection of
-            linestrings instead of polygons.
+        only_edges : bool (optional, default False)
+            If set to True, the triangulation will return linestrings instead of
+            polygons.
 
         Examples
         --------
 
-        >>> from shapely import LineString, MultiPoint, Polygon
+        >>> from shapely import LineString, MultiPoint, Point, Polygon
         >>> s = geopandas.GeoSeries(
         ...     [
-        ...         MultiPoint([(5, 3), (6, 3), (10, 10)]),
-        ...         Polygon([(5, 3), (6, 3), (10, 10), (5, 3)]),
-        ...         LineString([(5, 3), (6, 3), (10, 10)]),
+        ...         Point(1, 1),
+        ...         Point(2, 2),
+        ...         Point(1, 3),
+        ...         Point(0, 2),
         ...     ]
         ... )
         >>> s
-        0    MULTIPOINT ((5 3), (6 3), (10 10))
-        1      POLYGON ((5 3, 6 3, 10 10, 5 3))
-        2          LINESTRING (5 3, 6 3, 10 10)
+        0    POINT (1 1)
+        1    POINT (2 2)
+        2    POINT (1 3)
+        3    POINT (0 2)
         dtype: geometry
 
         >>> s.delaunay_triangles()
-        0    GEOMETRYCOLLECTION (POLYGON ((10 10, 5 3, 6 3,...
-        1    GEOMETRYCOLLECTION (POLYGON ((10 10, 5 3, 6 3,...
-        2    GEOMETRYCOLLECTION (POLYGON ((10 10, 5 3, 6 3,...
+        0    POLYGON ((0 2, 1 1, 1 3, 0 2))
+        1    POLYGON ((1 3, 1 1, 2 2, 1 3))
         dtype: geometry
 
         >>> s.delaunay_triangles(only_edges=True)
-        0    MULTILINESTRING ((5 3, 10 10), (5 3, 6 3), (6 ...
-        1    MULTILINESTRING ((5 3, 10 10), (5 3, 6 3), (6 ...
-        2    MULTILINESTRING ((5 3, 10 10), (5 3, 6 3), (6 ...
+        0    LINESTRING (1 3, 2 2)
+        1    LINESTRING (0 2, 1 3)
+        2    LINESTRING (0 2, 1 1)
+        3    LINESTRING (1 1, 2 2)
+        4    LINESTRING (1 1, 1 3)
+        dtype: geometry
+
+        The method supports any geometry type but keep in mind that the underlying
+        algorithm is based on the vertices of the input geometries only and does not
+        consider edge segments between vertices.
+
+        >>> s2 = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon([(0, 0), (1, 1), (0, 1)]),
+        ...         LineString([(1, 0), (2, 1), (1, 2)]),
+        ...         MultiPoint([(2, 3), (2, 0), (3, 1)]),
+        ...     ]
+        ... )
+        >>> s2
+        0      POLYGON ((0 0, 1 1, 0 1, 0 0))
+        1          LINESTRING (1 0, 2 1, 1 2)
+        2    MULTIPOINT ((2 3), (2 0), (3 1))
+        dtype: geometry
+
+        >>> s2.delaunay_triangles()
+        0    POLYGON ((0 1, 0 0, 1 0, 0 1))
+        1    POLYGON ((0 1, 1 0, 1 1, 0 1))
+        2    POLYGON ((0 1, 1 1, 1 2, 0 1))
+        3    POLYGON ((1 2, 1 1, 2 1, 1 2))
+        4    POLYGON ((1 2, 2 1, 2 3, 1 2))
+        5    POLYGON ((2 3, 2 1, 3 1, 2 3))
+        6    POLYGON ((3 1, 2 1, 2 0, 3 1))
+        7    POLYGON ((2 0, 2 1, 1 1, 2 0))
+        8    POLYGON ((2 0, 1 1, 1 0, 2 0))
         dtype: geometry
 
         See also
         --------
         GeoSeries.voronoi_polygons : Voronoi diagram around vertices
         """
-        return _delegate_geo_method(
-            "delaunay_triangles", self, tolerance=tolerance, only_edges=only_edges
+        from .geoseries import GeoSeries
+
+        geometry_input = shapely.geometrycollections(self.geometry.values._data)
+
+        delaunay = shapely.delaunay_triangles(
+            geometry_input,
+            tolerance=tolerance,
+            only_edges=only_edges,
         )
+        return GeoSeries(delaunay, crs=self.crs).explode(ignore_index=True)
 
     def voronoi_polygons(self, tolerance=0.0, extend_to=None, only_edges=False):
         """Returns a ``GeoSeries`` consisting of objects representing
@@ -4603,7 +4647,7 @@ GeometryCollection
         The operation works in a 1-to-1 row-wise manner:
 
         .. image:: ../../../_static/binary_op-01.svg
-        :align: center
+           :align: center
 
         Parameters
         ----------
@@ -4683,6 +4727,105 @@ GeometryCollection
         """
         return _binary_geo("snap", self, other, align, tolerance=tolerance)
 
+    def shared_paths(self, other, align=None):
+        """
+        Returns the shared paths between two geometries.
+
+        Geometries within the GeoSeries should be only (Multi)LineStrings or
+        LinearRings. A GeoSeries of GeometryCollections is returned with two elements
+        in each GeometryCollection. The first element is a MultiLineString containing
+        shared paths with the same direction for both inputs. The second element is a
+        MultiLineString containing shared paths with the opposite direction for the two
+        inputs.
+
+        You can extract individual geometries of the resulting GeometryCollection using
+        the :meth:`GeoSeries.get_geometry` method.
+
+        The operation works on a 1-to-1 row-wise manner:
+
+        .. image:: ../../../_static/binary_op-01.svg
+           :align: center
+
+        Parameters
+        ----------
+        other : Geoseries or geometric object
+            The Geoseries (elementwise) or geometric object to find the shared paths
+            with. Has to contain only (Multi)LineString or LinearRing geometry types.
+        align : bool | None (default None)
+            If True, automatically aligns GeoSeries based on their indices.
+            If False, the order of elements is preserved. None defaults to True.
+
+        Returns
+        -------
+        GeoSeries
+
+        Examples
+        --------
+        >>> from shapely.geometry import LineString, MultiLineString
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         LineString([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]),
+        ...         LineString([(1, 0), (2, 0), (2, 1), (1, 1), (1, 0)]),
+        ...         MultiLineString([[(1, 0), (2, 0)], [(2, 1), (1, 1), (1, 0)]]),
+        ...     ],
+        ... )
+        >>> s
+        0             LINESTRING (0 0, 1 0, 1 1, 0 1, 0 0)
+        1             LINESTRING (1 0, 2 0, 2 1, 1 1, 1 0)
+        2    MULTILINESTRING ((1 0, 2 0), (2 1, 1 1, 1 0))
+        dtype: geometry
+
+        We can find the shared paths between each geometry and a single shapely
+        geometry:
+
+        .. image:: ../../../_static/binary_op-03.svg
+           :align: center
+
+        >>> l = LineString([(1, 1), (0, 1)])
+        >>> s.shared_paths(l)
+        0    GEOMETRYCOLLECTION (MULTILINESTRING ((1 1, 0 1...
+        1    GEOMETRYCOLLECTION (MULTILINESTRING EMPTY, MUL...
+        2    GEOMETRYCOLLECTION (MULTILINESTRING EMPTY, MUL...
+        dtype: geometry
+
+        We can also check two GeoSeries against each other, row by row. The GeoSeries
+        above have different indices than the one below. We can either align both
+        GeoSeries based on index values and compare elements with the same index using
+        ``align=True`` or ignore index and compare elements based on their matching
+        order using ``align=False``:
+
+        .. image:: ../../../_static/binary_op-02.svg
+
+        >>> s2 = geopandas.GeoSeries(
+        ...     [
+        ...         LineString([(1, 1), (0, 1)]),
+        ...         LineString([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]),
+        ...         LineString([(1, 0), (2, 0), (2, 1), (1, 1), (1, 0)]),
+        ...     ],
+        ...     index=[1, 2, 3]
+        ... )
+
+        >>> s.shared_paths(s2, align=True)
+        0                                                 None
+        1    GEOMETRYCOLLECTION (MULTILINESTRING EMPTY, MUL...
+        2    GEOMETRYCOLLECTION (MULTILINESTRING EMPTY, MUL...
+        3                                                 None
+        dtype: geometry
+        >>>
+
+        >>> s.shared_paths(s2, align=False)
+        0    GEOMETRYCOLLECTION (MULTILINESTRING ((1 1, 0 1...
+        1    GEOMETRYCOLLECTION (MULTILINESTRING EMPTY, MUL...
+        2    GEOMETRYCOLLECTION (MULTILINESTRING ((1 0, 2 0...
+        dtype: geometry
+
+        See Also
+        --------
+        GeoSeries.get_geometry
+        """
+
+        return _binary_geo("shared_paths", self, other, align)
+
     #
     # Other operations
     #
diff --git a/geopandas/explore.py b/geopandas/explore.py
index 9457dce2..9970df44 100644
--- a/geopandas/explore.py
+++ b/geopandas/explore.py
@@ -7,6 +7,8 @@ import pandas as pd
 
 from packaging.version import Version
 
+from pandas.api.types import is_datetime64_any_dtype
+
 _MAP_KWARGS = [
     "location",
     "prefer_canvas",
@@ -316,6 +318,8 @@ def _explore(
         gdf.geometry[rings_mask] = gdf.geometry[rings_mask].apply(
             lambda g: LineString(g)
         )
+    if isinstance(gdf, geopandas.GeoSeries):
+        gdf = gdf.to_frame()
 
     if gdf.crs is None:
         kwargs["crs"] = "Simple"
@@ -323,6 +327,14 @@ def _explore(
     elif not gdf.crs.equals(4326):
         gdf = gdf.to_crs(4326)
 
+    # Fields which are not JSON serializable are coerced to strings
+    json_not_supported_cols = gdf.columns[
+        [is_datetime64_any_dtype(gdf[c]) for c in gdf.columns]
+    ].union(gdf.columns[gdf.dtypes == "object"])
+
+    if len(json_not_supported_cols) > 0:
+        gdf = gdf.astype({c: "string" for c in json_not_supported_cols})
+
     # create folium.Map object
     if m is None:
         # Get bounds to specify location and map extent


Test patch:
diff --git a/geopandas/tests/test_explore.py b/geopandas/tests/test_explore.py
index 0c24b5f6..7806f7ac 100644
--- a/geopandas/tests/test_explore.py
+++ b/geopandas/tests/test_explore.py
@@ -1,3 +1,5 @@
+import uuid
+
 import geopandas as gpd
 import numpy as np
 import pandas as pd
@@ -298,6 +300,34 @@ class TestExplore:
         assert '"__folium_color":"#9edae5","bool":true' in out2_str
         assert '"__folium_color":"#1f77b4","bool":false' in out2_str
 
+    def test_datetime(self):
+        df = self.nybb.copy().head(2)
+        date1 = pd.Timestamp(2022, 1, 1, 1, 22, 0, 0)
+        date2 = pd.Timestamp(2025, 1, 1, 1, 22, 0, 0)
+        df["datetime"] = [date1, date2]
+        m1 = df.explore("datetime")
+
+        out1_str = self._fetch_map_string(m1)
+        assert '"__folium_color":"#9edae5","datetime":"2025-01-0101:22:00"' in out1_str
+        assert '"__folium_color":"#1f77b4","datetime":"2022-01-0101:22:00"' in out1_str
+
+    def test_non_json_serialisable(self):
+        df = self.nybb.copy().head(2)
+        uuid1 = uuid.UUID("12345678123456781234567812345678")
+        uuid2 = uuid.UUID("12345678123456781234567812345679")
+        df["object"] = [uuid1, uuid2]
+        m1 = df.explore("object")
+
+        out1_str = self._fetch_map_string(m1)
+        assert (
+            '"__folium_color":"#9edae5","object":"12345678-1234-5678-1234-567812345679"}'
+            in out1_str
+        )
+        assert (
+            '"__folium_color":"#1f77b4","object":"12345678-1234-5678-1234-567812345678"'
+            in out1_str
+        )
+
     def test_string(self):
         df = self.nybb.copy()
         df["string"] = pd.array([1, 2, 3, 4, 5], dtype="string")
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index ce9bf54c..04004f50 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -1136,37 +1136,26 @@ class TestGeomMethods:
     def test_delaunay_triangles(self):
         expected = GeoSeries(
             [
-                GeometryCollection([Polygon([(0, 0), (1, 0), (1, 1), (0, 0)])]),
-                GeometryCollection([Polygon([(0, 1), (0, 0), (1, 1), (0, 1)])]),
-            ],
-            crs=self.g3.crs,
+                Polygon([(0, 1), (0, 0), (1, 0), (0, 1)]),
+                Polygon([(0, 1), (1, 0), (1, 1), (0, 1)]),
+            ]
         )
-        dlt = self.g3.delaunay_triangles()
+        dlt = self.g5.delaunay_triangles()
         assert_geoseries_equal(expected, dlt)
 
     def test_delaunay_triangles_pass_kwargs(self):
         expected = GeoSeries(
             [
-                MultiLineString([[(0, 0), (1, 1)], [(0, 0), (1, 0)], [(1, 0), (1, 1)]]),
-                MultiLineString([[(0, 1), (1, 1)], [(0, 0), (0, 1)], [(0, 0), (1, 1)]]),
+                LineString([(0, 1), (1, 1)]),
+                LineString([(0, 0), (0, 1)]),
+                LineString([(0, 0), (1, 0)]),
+                LineString([(1, 0), (1, 1)]),
+                LineString([(0, 1), (1, 0)]),
             ],
-            crs=self.g3.crs,
         )
-        dlt = self.g3.delaunay_triangles(only_edges=True)
+        dlt = self.g5.delaunay_triangles(only_edges=True)
         assert_geoseries_equal(expected, dlt)
 
-    def test_delaunay_triangles_wrong_index(self):
-        with pytest.raises(
-            ValueError,
-            match="Index of the Series passed as 'only_edges' does not match",
-        ):
-            self.g3.delaunay_triangles(only_edges=Series([True, False], index=[99, 98]))
-
-        with pytest.raises(
-            ValueError, match="Index of the Series passed as 'tolerance' does not match"
-        ):
-            self.g3.delaunay_triangles(tolerance=Series([0.1, 0.2], index=[99, 98]))
-
     def test_voronoi_polygons(self):
         expected = GeoSeries.from_wkt(
             [
@@ -2063,6 +2052,54 @@ class TestGeomMethods:
         )
         assert_geoseries_equal(expected, self.g1.force_3d([1, 2]))
 
+    def test_shared_paths(self):
+        line = LineString([(0, 0), (0.5, 0.5), (0, 1)])
+        expected = GeoSeries.from_wkt(
+            [
+                "GEOMETRYCOLLECTION (MULTILINESTRING ((0 0, 0.5 0.5)),"
+                " MULTILINESTRING EMPTY)",
+                "GEOMETRYCOLLECTION (MULTILINESTRING EMPTY,"
+                " MULTILINESTRING ((0 1, 0.5 0.5)))",
+            ]
+        )
+        assert_geoseries_equal(expected, self.crossed_lines.shared_paths(line))
+
+        s2 = GeoSeries(
+            [
+                LineString([(0, 0), (0.5, 0.5), (1, 0), (1, 1), (0.9, 0.9)]),
+                LineString([(1, 1), (0, 1), (1, 0)]),
+            ],
+            index=[1, 2],
+        )
+        expected = GeoSeries.from_wkt(
+            [
+                None,
+                "GEOMETRYCOLLECTION (MULTILINESTRING ((0.5 0.5, 1 0)),"
+                " MULTILINESTRING EMPTY)",
+                None,
+            ]
+        )
+
+        with pytest.warns(
+            UserWarning,
+            match="The indices of the left and right GeoSeries' are not equal",
+        ):
+            assert_geoseries_equal(
+                self.crossed_lines.shared_paths(s2, align=None), expected
+            )
+
+        expected = GeoSeries.from_wkt(
+            [
+                "GEOMETRYCOLLECTION (MULTILINESTRING ((0 0, 0.5 0.5)),"
+                " MULTILINESTRING ((0.9 0.9, 1 1)))",
+                "GEOMETRYCOLLECTION (MULTILINESTRING ((0 1, 1 0)),"
+                " MULTILINESTRING EMPTY)",
+            ]
+        )
+        assert_geoseries_equal(
+            self.crossed_lines.shared_paths(s2, align=False), expected
+        )
+
     def test_force_3d_wrong_index(self):
         with pytest.raises(
             ValueError, match="Index of the Series passed as 'z' does not match"