Skip to content

Commit 6777f4d

Browse files
authored
feat: Add A5Layer (#1001)
@felixpalmer Closes #968 <img width="529" height="616" alt="image" src="https://github.com/user-attachments/assets/abc29e32-4777-4262-a290-a68c57e43373" />
1 parent 432406c commit 6777f4d

File tree

9 files changed

+309
-30
lines changed

9 files changed

+309
-30
lines changed

lonboard/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from ._version import __version__
66
from ._viz import viz
77
from .layer import (
8+
A5Layer,
89
ArcLayer,
910
BaseArrowLayer,
1011
BaseLayer,
@@ -21,6 +22,7 @@
2122
)
2223

2324
__all__ = [
25+
"A5Layer",
2426
"ArcLayer",
2527
"BaseArrowLayer",
2628
"BaseLayer",

lonboard/layer/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# Then the default value in the JS GeoArrow layer (defined in
88
# `@geoarrow/deck.gl-layers`) will be used.
99

10+
from ._a5 import A5Layer
1011
from ._arc import ArcLayer
1112
from ._base import BaseArrowLayer, BaseLayer
1213
from ._bitmap import BitmapLayer, BitmapTileLayer
@@ -20,6 +21,7 @@
2021
from ._trips import TripsLayer
2122

2223
__all__ = [
24+
"A5Layer",
2325
"ArcLayer",
2426
"BaseArrowLayer",
2527
"BaseLayer",

lonboard/layer/_a5.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import traitlets as t
6+
7+
from lonboard._utils import auto_downcast as _auto_downcast
8+
9+
# Important to import from ._polygon to avoid circular imports
10+
from lonboard.layer._polygon import PolygonLayer
11+
from lonboard.traits import A5Accessor, ArrowTableTrait
12+
13+
if TYPE_CHECKING:
14+
import sys
15+
16+
import pandas as pd
17+
from arro3.core.types import ArrowStreamExportable
18+
19+
from lonboard.types.layer import A5AccessorInput, A5LayerKwargs
20+
21+
if sys.version_info >= (3, 11):
22+
from typing import Self
23+
else:
24+
from typing_extensions import Self
25+
26+
if sys.version_info >= (3, 12):
27+
from typing import Unpack
28+
else:
29+
from typing_extensions import Unpack
30+
31+
32+
class A5Layer(PolygonLayer):
33+
"""The `A5Layer` renders filled and/or stroked polygons based on the [A5](https://a5geo.org) geospatial indexing system."""
34+
35+
def __init__(
36+
self,
37+
table: ArrowStreamExportable,
38+
*,
39+
get_pentagon: A5AccessorInput,
40+
_rows_per_chunk: int | None = None,
41+
**kwargs: Unpack[A5LayerKwargs],
42+
) -> None:
43+
"""Create a new A5Layer.
44+
45+
Args:
46+
table: An Arrow table with properties to associate with the A5 pentagons.
47+
48+
Keyword Args:
49+
get_pentagon: The cell identifier of each A5 pentagon.
50+
kwargs: Extra args passed down as A5Layer attributes.
51+
52+
"""
53+
super().__init__(
54+
table=table,
55+
get_pentagon=get_pentagon,
56+
_rows_per_chunk=_rows_per_chunk,
57+
**kwargs,
58+
)
59+
60+
@classmethod
61+
def from_pandas(
62+
cls,
63+
df: pd.DataFrame,
64+
*,
65+
get_pentagon: A5AccessorInput,
66+
auto_downcast: bool = True,
67+
**kwargs: Unpack[A5LayerKwargs],
68+
) -> Self:
69+
"""Create a new A5Layer from a pandas DataFrame.
70+
71+
Args:
72+
df: a Pandas DataFrame with properties to associate with A5 pentagons.
73+
74+
Keyword Args:
75+
get_pentagon: A5 cell identifier of each A5 hexagon.
76+
auto_downcast: Whether to save memory on input by casting to smaller types. Defaults to True.
77+
kwargs: Extra args passed down as A5Layer attributes.
78+
79+
"""
80+
try:
81+
import pyarrow as pa
82+
except ImportError as e:
83+
raise ImportError(
84+
"pyarrow required for converting GeoPandas to arrow.\n"
85+
"Run `pip install pyarrow`.",
86+
) from e
87+
88+
if auto_downcast:
89+
# Note: we don't deep copy because we don't need to clone geometries
90+
df = _auto_downcast(df.copy()) # type: ignore
91+
92+
table = pa.Table.from_pandas(df)
93+
return cls(table, get_pentagon=get_pentagon, **kwargs)
94+
95+
_layer_type = t.Unicode("a5").tag(sync=True)
96+
97+
table = ArrowTableTrait(geometry_required=False)
98+
"""An Arrow table with properties to associate with the A5 pentagons.
99+
100+
If you have a Pandas `DataFrame`, use
101+
[`from_pandas`][lonboard.A5Layer.from_pandas] instead.
102+
"""
103+
104+
get_pentagon = A5Accessor()
105+
"""The cell identifier of each A5 pentagon.
106+
107+
Accepts either an array of strings or uint64 integers representing A5 cell IDs.
108+
109+
- Type: [A5Accessor][lonboard.traits.A5Accessor]
110+
"""

lonboard/layer/_h3.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,10 @@ def __init__(
130130
"""Create a new H3HexagonLayer.
131131
132132
Args:
133-
table: _description_
133+
table: An Arrow table with properties to associate with the H3 hexagons.
134134
135135
Keyword Args:
136-
get_hexagon: _description_
136+
get_hexagon: The cell identifier of each H3 hexagon.
137137
kwargs: Extra args passed down as H3HexagonLayer attributes.
138138
139139
"""
@@ -170,12 +170,6 @@ def from_pandas(
170170
auto_downcast: Whether to save memory on input by casting to smaller types. Defaults to True.
171171
kwargs: Extra args passed down as H3HexagonLayer attributes.
172172
173-
Raises:
174-
ImportError: _description_
175-
176-
Returns:
177-
_description_
178-
179173
"""
180174
try:
181175
import pyarrow as pa

lonboard/traits/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
documentation on how to define new traitlet types.
55
"""
66

7+
from ._a5 import A5Accessor
78
from ._base import FixedErrorTraitType, VariableLengthTuple
89
from ._color import ColorAccessor
910
from ._extensions import DashArrayAccessor, FilterValueAccessor
@@ -17,6 +18,7 @@
1718
from ._timestamp import TimestampAccessor
1819

1920
__all__ = [
21+
"A5Accessor",
2022
"ArrowTableTrait",
2123
"BasemapUrl",
2224
"ColorAccessor",

lonboard/traits/_a5.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# ruff: noqa: SLF001
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, Any
6+
7+
import numpy as np
8+
from arro3.core import Array, ChunkedArray, DataType
9+
10+
from lonboard._h3._str_to_h3 import str_to_h3
11+
from lonboard._serialization import ACCESSOR_SERIALIZATION
12+
from lonboard.traits._base import FixedErrorTraitType
13+
14+
if TYPE_CHECKING:
15+
import pandas as pd
16+
from numpy.typing import NDArray
17+
from traitlets.traitlets import TraitType
18+
19+
from lonboard.layer import BaseArrowLayer
20+
21+
22+
class A5Accessor(FixedErrorTraitType):
23+
"""A trait to validate A5 cell input.
24+
25+
Various input is allowed:
26+
27+
- A numpy `ndarray` with an object, S16, or uint64 data type.
28+
- A pandas `Series` with an object or uint64 data type.
29+
- A pyarrow string, large string, string view array, or uint64 array, or a chunked array of those types.
30+
- Any Arrow string, large string, string view array, or uint64 array, or a chunked array of those types from a library that implements the [Arrow PyCapsule
31+
Interface](https://arrow.apache.org/docs/format/CDataInterface/PyCapsuleInterface.html).
32+
"""
33+
34+
default_value = None
35+
info_text = (
36+
"a float value or numpy ndarray or Arrow array representing an array of floats"
37+
)
38+
39+
def __init__(
40+
self: TraitType,
41+
*args: Any,
42+
**kwargs: Any,
43+
) -> None:
44+
super().__init__(*args, **kwargs)
45+
self.tag(sync=True, **ACCESSOR_SERIALIZATION)
46+
47+
def _pandas_to_numpy(
48+
self,
49+
obj: BaseArrowLayer,
50+
value: pd.Series,
51+
) -> NDArray[np.str_] | NDArray[np.uint64]:
52+
"""Cast pandas Series to numpy ndarray."""
53+
if isinstance(value.dtype, np.dtype) and np.issubdtype(value.dtype, np.integer):
54+
return np.asarray(value, dtype=np.uint64)
55+
56+
if not isinstance(value.dtype, np.dtype) or not np.issubdtype(
57+
value.dtype,
58+
np.object_,
59+
):
60+
self.error(
61+
obj,
62+
value,
63+
info="A5 Pandas series not object or uint64 dtype.",
64+
)
65+
66+
if not (value.str.len() == 16).all():
67+
self.error(
68+
obj,
69+
value,
70+
info="A5 Pandas series not all 16 characters long.",
71+
)
72+
73+
return np.asarray(value, dtype="S16")
74+
75+
def _numpy_to_arrow(self, obj: BaseArrowLayer, value: np.ndarray) -> ChunkedArray:
76+
if np.issubdtype(value.dtype, np.uint64):
77+
return ChunkedArray([value])
78+
79+
if np.issubdtype(value.dtype, np.object_):
80+
if {len(v) for v in value} != {16}:
81+
self.error(
82+
obj,
83+
value,
84+
info="numpy object array not all 16 characters long",
85+
)
86+
87+
value = np.asarray(value, dtype="S16")
88+
89+
if not np.issubdtype(value.dtype, np.dtype("S16")):
90+
self.error(obj, value, info="numpy array not object, str, or uint64 dtype")
91+
92+
a5_uint8_array = str_to_h3(value)
93+
return ChunkedArray([a5_uint8_array])
94+
95+
def validate(self, obj: BaseArrowLayer, value: Any) -> ChunkedArray:
96+
# pandas Series
97+
if (
98+
value.__class__.__module__.startswith("pandas")
99+
and value.__class__.__name__ == "Series"
100+
):
101+
value = self._pandas_to_numpy(obj, value)
102+
103+
if isinstance(value, np.ndarray):
104+
value = self._numpy_to_arrow(obj, value)
105+
elif hasattr(value, "__arrow_c_array__"):
106+
value = ChunkedArray([Array.from_arrow(value)])
107+
elif hasattr(value, "__arrow_c_stream__"):
108+
value = ChunkedArray.from_arrow(value)
109+
else:
110+
self.error(obj, value)
111+
112+
assert isinstance(value, ChunkedArray)
113+
114+
if (
115+
DataType.is_string(value.type)
116+
or DataType.is_large_string(value.type)
117+
or DataType.is_string_view(value.type)
118+
):
119+
value = self._numpy_to_arrow(obj, value.to_numpy())
120+
121+
if not DataType.is_uint64(value.type):
122+
self.error(
123+
obj,
124+
value,
125+
info="A5 Arrow array must be uint64 type.",
126+
)
127+
128+
return value.rechunk(max_chunksize=obj._rows_per_chunk)

lonboard/types/layer.py

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
ArrowArrayExportable,
6363
ArrowStreamExportable,
6464
]
65+
A5AccessorInput = H3AccessorInput
6566
NormalAccessorInput = Union[
6667
list[int],
6768
tuple[int, int, int],
@@ -169,25 +170,6 @@ class ColumnLayerKwargs(BaseLayerKwargs, total=False):
169170
get_line_width: FloatAccessorInput
170171

171172

172-
class H3HexagonLayerKwargs(BaseLayerKwargs, total=False):
173-
high_precision: bool
174-
stroked: bool
175-
filled: bool
176-
extruded: bool
177-
wireframe: bool
178-
elevation_scale: IntFloat
179-
line_width_units: Units
180-
line_width_scale: IntFloat
181-
line_width_min_pixels: IntFloat
182-
line_width_max_pixels: IntFloat
183-
line_joint_rounded: bool
184-
line_miter_limit: IntFloat
185-
get_fill_color: ColorAccessorInput
186-
get_line_color: ColorAccessorInput
187-
get_line_width: FloatAccessorInput
188-
get_elevation: FloatAccessorInput
189-
190-
191173
class PathLayerKwargs(BaseLayerKwargs, total=False):
192174
width_units: Units
193175
width_scale: IntFloat
@@ -226,6 +208,14 @@ class PolygonLayerKwargs(BaseLayerKwargs, total=False):
226208
get_elevation: FloatAccessorInput
227209

228210

211+
class H3HexagonLayerKwargs(PolygonLayerKwargs, total=False):
212+
high_precision: bool
213+
214+
215+
class A5LayerKwargs(PolygonLayerKwargs, total=False):
216+
pass
217+
218+
229219
class ScatterplotLayerKwargs(BaseLayerKwargs, total=False):
230220
radius_units: Units
231221
radius_scale: IntFloat

0 commit comments

Comments
 (0)