Skip to content

Commit 64dd244

Browse files
authored
Merge pull request #1 from Filimoa/graphing-utils
Adding Graphing Helpers
2 parents 71d079a + 8acdd22 commit 64dd244

File tree

9 files changed

+228
-14
lines changed

9 files changed

+228
-14
lines changed

Cargo.lock

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "polars-h3"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
edition = "2021"
55

66
[lib]

README.md

+18
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,21 @@ We are unable to support the functions that work with geometries.
117117
| `polygon_wkt_to_cells` | Convert polygon WKT to a set of cells | 🛑 |
118118
| `directed_edge_to_boundary_wkt` | Convert directed edge ID to linestring WKT | 🛑 |
119119

120+
121+
### Plotting
122+
123+
The library also comes with helper functions to plot hexes on a Folium map.
124+
125+
126+
```python
127+
import polars_h3 as pl_h3
128+
import polars as pl
129+
130+
hex_map = pl_h3.graphing.plot_hex_outlines(df, "h3_cell")
131+
display(hex_map)
132+
133+
# or if you have a metric to plot
134+
135+
hex_map = pl_h3.graphing.plot_hex_fills(df, "h3_cell", "metric_col")
136+
display(hex_map)
137+
```

polars_h3/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from . import graphing
12
from .core.edge import (
23
are_neighbor_cells,
34
cells_to_directed_edge,
@@ -100,4 +101,5 @@
100101
"edge_length",
101102
"average_hexagon_edge_length",
102103
"get_num_cells",
104+
"graphing",
103105
]

polars_h3/graphing.py

+189
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
from typing import Any, Literal, Union
2+
3+
import polars as pl
4+
5+
from .core.indexing import cell_to_boundary
6+
7+
8+
def _hex_bounds(
9+
df: pl.DataFrame, boundary_col: str = "boundary"
10+
) -> tuple[tuple[float, float], tuple[float, float]]:
11+
df_flat = (
12+
df.explode(boundary_col)
13+
.with_columns(
14+
[
15+
pl.col(boundary_col).list.get(0).alias("lat"),
16+
pl.col(boundary_col).list.get(1).alias("lng"),
17+
]
18+
)
19+
.drop(boundary_col)
20+
)
21+
22+
min_lat = float(df_flat["lat"].min()) # type: ignore
23+
max_lat = float(df_flat["lat"].max()) # type: ignore
24+
min_lng = float(df_flat["lng"].min()) # type: ignore
25+
max_lng = float(df_flat["lng"].max()) # type: ignore
26+
27+
return ((min_lat, min_lng), (max_lat, max_lng))
28+
29+
30+
def plot_hex_outlines(
31+
df: pl.DataFrame,
32+
hex_id_col: str,
33+
map: Union[Any, None] = None,
34+
outline_color: str = "red",
35+
map_size: Literal["medium", "large"] = "medium",
36+
) -> Any:
37+
"""
38+
Plot hexagon outlines on a Folium map.
39+
40+
Parameters
41+
----------
42+
df : pl.DataFrame
43+
A DataFrame that must contain a column of hex IDs.
44+
hex_id_col : str
45+
The name of the column in `df` that contains hexagon identifiers (H3 cell IDs).
46+
map : folium.Map or None, optional
47+
An existing Folium map object on which to plot. If None, a new map is created.
48+
outline_color : str, optional
49+
The color used to outline the hexagons. Defaults to "red".
50+
map_size : {"medium", "large"}, optional
51+
The size of the displayed map. "medium" fits a 50% view, "large" takes 100%. Defaults to "medium".
52+
53+
Returns
54+
-------
55+
folium.Map
56+
A Folium map object with hexagon outlines added.
57+
58+
Raises
59+
------
60+
ValueError
61+
If the input DataFrame is empty.
62+
ImportError
63+
If Folium is not installed.
64+
"""
65+
if df.height == 0:
66+
raise ValueError("DataFrame is empty")
67+
68+
try:
69+
import folium
70+
except ImportError as e:
71+
raise ImportError(
72+
"folium is required to plot hex outlines. Install with `pip install folium`"
73+
) from e
74+
75+
if not map:
76+
map = folium.Map(
77+
zoom_start=13,
78+
tiles="cartodbpositron",
79+
width="50%" if map_size == "medium" else "100%",
80+
height="50%" if map_size == "medium" else "100%",
81+
)
82+
83+
df = df.with_columns(
84+
[
85+
cell_to_boundary(pl.col(hex_id_col)).alias("boundary"),
86+
]
87+
).filter(pl.col("boundary").is_not_null())
88+
89+
for hex_cord in df["boundary"].to_list():
90+
folium.Polygon(locations=hex_cord, weight=5, color=outline_color).add_to(map)
91+
92+
map_bounds = _hex_bounds(df, "boundary")
93+
map.fit_bounds(map_bounds)
94+
return map
95+
96+
97+
def plot_hex_fills(
98+
df: pl.DataFrame,
99+
hex_id_col: str,
100+
metric_col: str,
101+
map: Union[Any, None] = None,
102+
map_size: Literal["medium", "large"] = "medium",
103+
) -> Any:
104+
"""
105+
Render filled hexagonal cells on a Folium map, colorized by a specified metric.
106+
107+
If no map is provided, a new Folium map is created. The map is automatically
108+
fit to the bounds of the plotted polygons.
109+
110+
#### Parameters
111+
- `df`: pl.DataFrame
112+
- `hex_id_col`: str
113+
Column name in `df` holding H3 cell indices.
114+
- `metric_col`: str
115+
Column name in `df` containing the metric values for colorization.
116+
- `map`: folium.Map | None, default None
117+
An existing Folium Map object. If None, a new map is created.
118+
- `map_size`: Literal["medium", "large"], default "medium"
119+
Controls the size of the Folium map. `"medium"` sets width/height to 50% while `"large"` sets it to 100%.
120+
121+
#### Returns
122+
folium.Map
123+
The Folium Map object with the rendered hexagon polygons.
124+
"""
125+
if df.height == 0:
126+
raise ValueError("DataFrame is empty")
127+
128+
try:
129+
import folium
130+
import matplotlib
131+
except ImportError as e:
132+
raise ImportError(
133+
"folium and matplotlib are required to plot hex fills. Install with `pip install folium matplotlib`"
134+
) from e
135+
136+
if not map:
137+
map = folium.Map(
138+
zoom_start=13,
139+
tiles="cartodbpositron",
140+
width="50%" if map_size == "medium" else "100%",
141+
height="50%" if map_size == "medium" else "100%",
142+
)
143+
144+
df = df.with_columns(
145+
[
146+
cell_to_boundary(pl.col(hex_id_col)).alias("boundary"),
147+
pl.col(metric_col).log1p().alias("normalized_metric"),
148+
]
149+
).filter(pl.col("boundary").is_not_null())
150+
151+
hexagons = df[hex_id_col].to_list()
152+
metrics = df[metric_col].to_list()
153+
compressed_metrics = df["normalized_metric"].to_list()
154+
boundaries = df["boundary"].to_list()
155+
156+
min_val = min(compressed_metrics)
157+
max_val = max(compressed_metrics)
158+
159+
if max_val == min_val:
160+
normalized_metrics = [0.0] * len(compressed_metrics)
161+
else:
162+
normalized_metrics = [
163+
(x - min_val) / (max_val - min_val) for x in compressed_metrics
164+
]
165+
166+
colormap = matplotlib.colormaps.get_cmap("plasma")
167+
168+
for (hexagon, metric, boundary), norm_metric in zip(
169+
zip(hexagons, metrics, boundaries), normalized_metrics, strict=False
170+
):
171+
rgba = colormap(norm_metric)
172+
color = (
173+
f"#{int(rgba[0] * 255):02x}{int(rgba[1] * 255):02x}{int(rgba[2] * 255):02x}"
174+
)
175+
176+
folium.Polygon(
177+
locations=boundary,
178+
fill=True,
179+
fill_opacity=0.6 + 0.4 * norm_metric,
180+
fill_color=color,
181+
color=color,
182+
weight=1,
183+
tooltip=f"{hexagon}<br>Value: {metric}",
184+
).add_to(map)
185+
186+
map_bounds = _hex_bounds(df, "boundary")
187+
map.fit_bounds(map_bounds)
188+
189+
return map

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ homepage = "https://github.com/Filimonov/polars-h3"
77

88
[project]
99
name = "polars-h3"
10-
version = "0.3.0"
10+
version = "0.4.0"
1111
description = "H3 bindings for Polars"
1212
readme = "README.md"
1313
requires-python = ">=3.9"

src/engine/indexing.rs

+14-8
Original file line numberDiff line numberDiff line change
@@ -109,21 +109,27 @@ pub fn cell_to_latlng(cell_series: &Series) -> PolarsResult<Series> {
109109
}
110110

111111
pub fn cell_to_boundary(cell_series: &Series) -> PolarsResult<Series> {
112-
use crate::engine::utils::parse_cell_indices;
113112
let cells = parse_cell_indices(cell_series)?;
114113

115114
let coords: ListChunked = cells
116115
.into_par_iter()
117116
.map(|cell| {
118117
cell.map(|idx| {
119118
let boundary = idx.boundary();
120-
// Convert boundary vertices into a flat Vec<f64> of [lat, lng, lat, lng, ...]
121-
let mut latlngs = Vec::with_capacity(boundary.len() * 2);
122-
for vertex in boundary.iter() {
123-
latlngs.push(vertex.lat());
124-
latlngs.push(vertex.lng());
125-
}
126-
Series::new(PlSmallStr::from(""), &latlngs)
119+
120+
// Create a Vec<Vec<f64>> for the boundary: each inner vec is [lat, lng]
121+
let latlng_pairs: Vec<Vec<f64>> = boundary
122+
.iter()
123+
.map(|vertex| vec![vertex.lat(), vertex.lng()])
124+
.collect();
125+
126+
// Convert each [lat, lng] pair into its own Series
127+
let inner_series: Vec<Series> = latlng_pairs
128+
.into_iter()
129+
.map(|coords| Series::new(PlSmallStr::from(""), coords))
130+
.collect();
131+
132+
Series::new(PlSmallStr::from(""), inner_series)
127133
})
128134
})
129135
.collect();

src/expressions.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ fn is_valid_vertex(inputs: &[Series]) -> PolarsResult<Series> {
312312
fn boundary_list_dtype(input_fields: &[Field]) -> PolarsResult<Field> {
313313
Ok(Field::new(
314314
input_fields[0].name.clone(),
315-
DataType::List(Box::new(DataType::Float64)),
315+
DataType::List(Box::new(DataType::List(Box::new(DataType::Float64)))),
316316
))
317317
}
318318

tests/test_indexing.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,6 @@ def test_cell_to_boundary_known(test_params):
162162
)
163163
boundary = df["boundary"][0]
164164
for i, (exp_lat, exp_lng) in enumerate(test_params["output_boundary"]):
165-
lat = boundary[i * 2]
166-
lng = boundary[i * 2 + 1]
165+
lat, lng = boundary[i]
167166
assert pytest.approx(lat, abs=1e-7) == exp_lat
168167
assert pytest.approx(lng, abs=1e-7) == exp_lng

0 commit comments

Comments
 (0)