Skip to content

Commit

Permalink
Merge pull request #617 from knaaptime/explore
Browse files Browse the repository at this point in the history
[WIP] explore method for graph
  • Loading branch information
martinfleis authored Nov 3, 2023
2 parents 3976997 + ce54246 commit 4290c11
Show file tree
Hide file tree
Showing 4 changed files with 286 additions and 3 deletions.
109 changes: 109 additions & 0 deletions libpysal/graph/_plotting.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import geopandas as gpd
import numpy as np
import pandas as pd
import shapely
Expand Down Expand Up @@ -136,3 +137,111 @@ def _plot(
ax.scatter(coords[:, 0], coords[:, 1], **node_kws, zorder=2)

return ax


def _explore_graph(
g,
gdf,
focal=None,
nodes=True,
color="black",
edge_kws=None,
node_kws=None,
focal_kws=None,
m=None,
):
"""Plot graph as an interactive Folium Map
Parameters
----------
g : libpysal.Graph
graph to be plotted
gdf : geopandas.GeoDataFrame
geodataframe used to instantiate to Graph
focal : list, optional
subset of focal observations to plot in the map, by default None.
If none, all relationships are plotted
nodes : bool, optional
whether to display observations as nodes in the map, by default True
color : str, optional
color applied to nodes and edges, by default "black"
edge_kws : dict, optional
additional keyword arguments passed to geopandas explore function
when plotting edges, by default None
node_kws : dict, optional
additional keyword arguments passed to geopandas explore function
when plotting nodes, by default None
focal_kws : dict, optional
additional keyword arguments passed to geopandas explore function
when plotting focal observations, by default None. Only applicable when
passing a subset of nodes with the `focal` argument
m : Folilum.Map, optional
folium map objecto to plot on top of, by default None
Returns
-------
folium.Map
folium map
"""
geoms = gdf.centroid.reindex(g.unique_ids)

if node_kws is not None:
if "color" not in node_kws:
node_kws["color"] = color
else:
node_kws = {"color": color}

if focal_kws is not None:
if "color" not in node_kws:
focal_kws["color"] = color
else:
focal_kws = {"color": color}

if edge_kws is not None:
if "color" not in edge_kws:
edge_kws["color"] = color
else:
edge_kws = {"color": color}

coords = shapely.get_coordinates(geoms)

if focal is not None:
if not pd.api.types.is_list_like(focal):
focal = [focal]
subset = g._adjacency[focal]
codes = subset.index.codes
adj = subset

else:
codes = g._adjacency.index.codes
adj = g._adjacency

# avoid plotting both ij and ji
edges, indices = np.unique(
np.sort(np.column_stack([codes]).T, axis=1), return_index=True, axis=0
)
lines = coords[edges]
lines = gpd.GeoSeries(
shapely.linestrings(lines),
crs=gdf.crs,
)
adj = adj.iloc[indices].reset_index()
edges = gpd.GeoDataFrame(adj, geometry=lines)[
["focal", "neighbor", "weight", "geometry"]
]

m = edges.explore(m=m, **edge_kws) if m is not None else edges.explore(**edge_kws)

if nodes is True:
if focal is not None:
# destinations
geoms.iloc[np.unique(subset.index.codes[1])].explore(m=m, **node_kws)
if focal_kws is None:
focal_kws = {}
# focals
geoms.iloc[np.unique(subset.index.codes[0])].explore(
m=m, **dict(node_kws, **focal_kws)
)
else:
geoms.explore(m=m, **node_kws)
return m
56 changes: 55 additions & 1 deletion libpysal/graph/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
)
from ._kernel import _distance_band, _kernel
from ._parquet import _read_parquet, _to_parquet
from ._plotting import _plot
from ._plotting import _explore_graph, _plot
from ._set_ops import SetOpsMixin
from ._spatial_lag import _lag_spatial
from ._triangulation import _delaunay, _gabriel, _relative_neighborhood, _voronoi
Expand Down Expand Up @@ -1278,6 +1278,60 @@ def plot(
limit_extent=limit_extent,
)

def explore(
self,
gdf,
focal=None,
nodes=True,
color="black",
edge_kws=None,
node_kws=None,
focal_kws=None,
m=None,
):
"""Plot graph as an interactive Folium Map
Parameters
----------
gdf : geopandas.GeoDataFrame
geodataframe used to instantiate to Graph
focal : list, optional
subset of focal observations to plot in the map, by default None.
If none, all relationships are plotted
nodes : bool, optional
whether to display observations as nodes in the map, by default True
color : str, optional
color applied to nodes and edges, by default "black"
edge_kws : dict, optional
additional keyword arguments passed to geopandas explore function
when plotting edges, by default None
node_kws : dict, optional
additional keyword arguments passed to geopandas explore function
when plotting nodes, by default None
focal_kws : dict, optional
additional keyword arguments passed to geopandas explore function
when plotting focal observations, by default None. Only applicable when
passing a subset of nodes with the `focal` argument
m : Folilum.Map, optional
folium map objecto to plot on top of, by default None
Returns
-------
folium.Map
folium map
"""
return _explore_graph(
self,
gdf,
focal=focal,
nodes=nodes,
color=color,
edge_kws=edge_kws,
node_kws=node_kws,
focal_kws=focal_kws,
m=m,
)


def _arrange_arrays(heads, tails, weights, ids=None):
"""
Expand Down
119 changes: 117 additions & 2 deletions libpysal/graph/tests/test_plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
import shapely

from libpysal import graph

matplotlib = pytest.importorskip("matplotlib")
from libpysal.graph.tests.test_utils import fetch_map_string


class TestPlotting:
def setup_method(self):
_ = pytest.importorskip("matplotlib")

self.nybb = geopandas.read_file(geodatasets.get_path("nybb"))
self.G = graph.Graph.build_contiguity(self.nybb)

Expand Down Expand Up @@ -253,3 +254,117 @@ def test_focal_kws(self):
np.testing.assert_array_equal(
pathcollection_focal.get_facecolor(), np.array([[0.0, 0.0, 1.0, 1.0]])
)


class TestExplore:
def setup_method(self):
# skip tests when no folium installed
pytest.importorskip("folium")

self.nybb_str = geopandas.read_file(geodatasets.get_path("nybb")).set_index(
"BoroName"
)
self.G_str = graph.Graph.build_contiguity(self.nybb_str)

def test_default(self):
m = self.G_str.explore(self.nybb_str)
s = fetch_map_string(m)

# nodes
assert s.count("Point") == 5
# edges
assert s.count("LineString") == 6
# tooltip
assert '"focal":"Queens","neighbor":"Bronx","weight":1}' in s
# color
assert s.count('"__folium_color":"black"') == 11
# labels
assert s.count("Brooklyn") == 3

def test_no_nodes(self):
m = self.G_str.explore(self.nybb_str, nodes=False)
s = fetch_map_string(m)

# nodes
assert s.count("Point") == 0
# edges
assert s.count("LineString") == 6
# tooltip
assert '"focal":"Queens","neighbor":"Bronx","weight":1}' in s
# color
assert s.count('"__folium_color":"black"') == 6
# labels
assert s.count("Brooklyn") == 2

def test_focal(self):
m = self.G_str.explore(self.nybb_str, focal="Queens")
s = fetch_map_string(m)

# nodes
assert s.count("Point") == 4
# edges
assert s.count("LineString") == 3
# tooltip
assert '"focal":"Queens","neighbor":"Bronx","weight":1}' in s
assert '"focal":"Queens","neighbor":"Manhattan","weight":1}' in s
assert '"focal":"Queens","neighbor":"Brooklyn","weight":1}' in s
# color
assert s.count('"__folium_color":"black"') == 7
# labels
assert s.count("Brooklyn") == 2

def test_focal_array(self):
m = self.G_str.explore(self.nybb_str, focal=["Queens", "Bronx"])
s = fetch_map_string(m)

# if node is both focal and neighbor, both are plottted as you can style
# them differently to see both
assert s.count("Point") == 6
# edges
assert s.count("LineString") == 4
# tooltip
assert '"focal":"Queens","neighbor":"Bronx","weight":1}' in s
assert '"focal":"Queens","neighbor":"Manhattan","weight":1}' in s
assert '"focal":"Queens","neighbor":"Brooklyn","weight":1}' in s
assert '"focal":"Bronx","neighbor":"Manhattan","weight":1}' in s

# color
assert s.count('"__folium_color":"black"') == 10
# labels
assert s.count("Brooklyn") == 2

def test_color(self):
m = self.G_str.explore(self.nybb_str, color="red")
s = fetch_map_string(m)

assert s.count('"__folium_color":"red"') == 11

def test_kws(self):
m = self.G_str.explore(
self.nybb_str,
focal=["Queens", "Bronx"],
edge_kws={"color": "red"},
node_kws={"color": "blue", "marker_kwds": {"radius": 8}},
focal_kws={"color": "pink", "marker_kwds": {"radius": 12}},
)
s = fetch_map_string(m)

# color
assert s.count('"__folium_color":"red"') == 4
assert s.count('"__folium_color":"blue"') == 4
assert s.count('"__folium_color":"pink"') == 2

assert '"radius":8' in s
assert '"radius":12' in s

def test_m(self):
m = self.nybb_str.explore()
self.G_str.explore(self.nybb_str, m=m)
s = fetch_map_string(m)

# nodes
assert s.count("Point") == 5
# edges
assert s.count("LineString") == 6
# geoms
assert s.count("Polygon") == 5
5 changes: 5 additions & 0 deletions libpysal/graph/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,8 @@ def test_validate_raises(
_validate_geometry_input(
numpy.arange(20).reshape(-1, 2), valid_geometry_types=contiguity_types
)

def fetch_map_string(m):
out = m._parent.render()
out_str = "".join(out.split())
return out_str

0 comments on commit 4290c11

Please sign in to comment.