from abc import ABCMeta, abstractmethod
[docs]
class H3Shape(metaclass=ABCMeta):
"""
Abstract parent class of ``LatLngPoly`` and ``LatLngMultiPoly``.
"""
@property
@abstractmethod
def __geo_interface__(self):
""" https://github.com/pytest-dev/pytest-cov/issues/428 """
[docs]
class LatLngPoly(H3Shape):
"""
Container for loops of lat/lng points describing a polygon, possibly with holes.
Attributes
----------
outer : list[tuple[float, float]]
List of lat/lng points describing the outer loop of the polygon
holes : list[list[tuple[float, float]]]
List of loops of lat/lng points describing the holes of the polygon
Examples
--------
A polygon with a single outer ring consisting of 4 points, having no holes:
>>> LatLngPoly(
... [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34), (37.82, -122.54)],
... )
<LatLngPoly: [4]>
The same polygon, but with one hole consisting of 3 points:
>>> LatLngPoly(
... [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34), (37.82, -122.54)],
... [(37.76, -122.51), (37.76, -122.44), (37.81, -122.51)],
... )
<LatLngPoly: [4/(3,)]>
The same as above, but with one additional hole, made up of 5 points:
>>> LatLngPoly(
... [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34), (37.82, -122.54)],
... [(37.76, -122.51), (37.76, -122.44), (37.81, -122.51)],
... [(37.71, -122.43), (37.71, -122.37), (37.73, -122.37), (37.75, -122.41),
... (37.73, -122.43)],
... )
<LatLngPoly: [4/(3, 5)]>
"""
def __init__(self, outer, *holes):
loops = [outer] + list(holes)
for loop in loops:
if len(loop) in (1, 2):
raise ValueError('Non-empty LatLngPoly loops need at least 3 points.')
point_dimensions = set(map(len, loop))
# empty set is possible for empty polygons, so we check if a subset
if not (point_dimensions <= {2}):
raise ValueError('LatLngPoly only accepts 2D points: lat/lng.')
self.outer = tuple(_open_ring(outer))
self.holes = tuple(
_open_ring(hole)
for hole in holes
)
def __repr__(self):
return '<LatLngPoly: {}>'.format(self.loopcode)
def __len__(self):
"""
Should this be the number of points in the outer loop,
the number of holes (or +1 for the outer loop)?
"""
raise NotImplementedError('No clear definition of length for LatLngPoly.')
@property
def loopcode(self):
""" Short code for describing the length of the outer loop and each hole
Example: ``[382/(18, 6, 6)]`` indicates an outer loop of 382 points,
along with 3 holes with 18, 6, and 6 points, respectively.
Example: ``[15]`` indicates an outer loop of 15 points and no holes.
"""
outer = len(self.outer)
holes = tuple(map(len, self.holes))
outer = str(outer)
if holes:
out = outer + '/' + str(holes)
else:
out = outer
return '[' + out + ']'
@property
def __geo_interface__(self):
ll2 = _polygon_to_LL2(self)
gj_dict = _LL2_to_geojson_dict(ll2)
return gj_dict
[docs]
class LatLngMultiPoly(H3Shape):
"""
Container for multiple ``LatLngPoly`` polygons.
Attributes
----------
polys : list[LatLngPoly]
List of lat/lng points describing the outer loop of the polygon
"""
def __init__(self, *polys):
self.polys = tuple(polys)
for p in self.polys:
if not isinstance(p, LatLngPoly):
raise ValueError('LatLngMultiPoly requires each input to be an LatLngPoly object, instead got: ' + str(p)) # noqa
def __repr__(self):
out = [p.loopcode for p in self.polys]
out = ', '.join(out)
out = '<LatLngMultiPoly: {}>'.format(out)
return out
def __iter__(self):
return iter(self.polys)
def __len__(self):
""" Give the number of polygons in this multi-polygon.
"""
"""
TODO: Pandas series or dataframe representation changes depending
on if __len__ is defined.
I'd prefer the one that states `LatLngMultiPoly`.
It seems like Pandas is assuming an iterable is best-described
by its elements when choosing the representation.
when __len__ *IS NOT* defined:
0 <LatLngMultiPoly: [368], [20], [6]>
1 <LatLngMultiPoly: [632/(6, 6, 6, 6, 6)], [290/(6,)...
2 <LatLngMultiPoly: [490/(6, 6, 10, 10, 14, 10, 6)],...
3 <LatLngMultiPoly: [344/(6,)], [22], [6], [10], [6]...
4 <LatLngMultiPoly: [382/(18, 6, 6)], [32], [6], [18...
when __len__ *IS* defined:
0 (<LatLngPoly: [368]>, <LatLngPoly: [20]>, <LatLngPoly: [6]>)
1 (<LatLngPoly: [632/(6, 6, 6, 6, 6)]>, <LatLngPoly: [29...
2 (<LatLngPoly: [490/(6, 6, 10, 10, 14, 10, 6)]>, <H...
3 (<LatLngPoly: [344/(6,)]>, <LatLngPoly: [22]>, <LatLngPoly...
4 (<LatLngPoly: [382/(18, 6, 6)]>, <LatLngPoly: [32]>, <...
"""
return len(self.polys)
def __getitem__(self, index):
return self.polys[index]
@property
def __geo_interface__(self):
ll3 = _mpoly_to_LL3(self)
gj_dict = _LL3_to_geojson_dict(ll3)
return gj_dict
"""
Helpers for cells_to_geojson and geojson_to_cells.
Dealing with GeoJSON Polygons and MultiPolygons can be confusing because
there are so many nested lists. To help keep track, we use the following
symbols to denote different levels of nesting.
LL0: lat/lng or lng/lat pair
LL1: list of LL0s
LL2: list of LL1s (i.e., a polygon with holes)
LL3: list of LL2s (i.e., several polygons with holes)
## TODO
- Allow user to specify "container" in `cells_to_geojson`.
- That is, they may want a MultiPolygon even if the output fits in a Polygon
- 'auto', Polygon, MultiPolygon, FeatureCollection, GeometryCollection, ...
"""
def _mpoly_to_LL3(mpoly):
ll3 = tuple(
_polygon_to_LL2(poly)
for poly in mpoly
)
return ll3
def _LL3_to_mpoly(ll3):
polys = [
_LL2_to_polygon(ll2)
for ll2 in ll3
]
mpoly = LatLngMultiPoly(*polys)
return mpoly
def _polygon_to_LL2(poly):
ll2 = [poly.outer] + list(poly.holes)
ll2 = tuple(
_close_ring(_swap_latlng(ll1))
for ll1 in ll2
)
return ll2
def _remove_z(ll1):
ll1 = [(a, b) for a, b, *z in ll1]
return ll1
def _LL2_to_polygon(ll2):
ll2 = [
_remove_z(ll1)
for ll1 in ll2
]
ll2 = [
_swap_latlng(ll1)
for ll1 in ll2
]
h3poly = LatLngPoly(*ll2)
return h3poly
def _LL2_to_geojson_dict(ll2):
gj_dict = {
'type': 'Polygon',
'coordinates': ll2,
}
return gj_dict
def _LL3_to_geojson_dict(ll3):
gj_dict = {
'type': 'MultiPolygon',
'coordinates': ll3,
}
return gj_dict
def _swap_latlng(ll1):
ll1 = tuple(
(b, a) for a, b in ll1
)
return ll1
def _close_ring(ll1):
"""
Idempotent
"""
if ll1 and (ll1[0] != ll1[-1]):
ll1 = tuple(ll1) + (ll1[0],)
return ll1
def _open_ring(ll1):
"""
Idempotent
"""
if ll1 and (ll1[0] == ll1[-1]):
ll1 = ll1[:-1]
return ll1
[docs]
def geo_to_h3shape(geo):
"""
Translate from ``__geo_interface__`` to H3Shape.
``geo`` either implements ``__geo_interface__`` or is a dict matching the format
Returns
-------
H3Shape
"""
# geo can be dict, a __geo_interface__, a string, LatLngPoly or LatLngMultiPoly
if isinstance(geo, H3Shape):
return geo
if hasattr(geo, '__geo_interface__'):
# get dict
geo = geo.__geo_interface__
assert isinstance(geo, dict) # todo: remove
t = geo['type']
coord = geo['coordinates']
if t == 'Polygon':
ll2 = coord
shape = _LL2_to_polygon(ll2)
elif t == 'MultiPolygon':
ll3 = coord
shape = _LL3_to_mpoly(ll3)
else:
raise ValueError('Unrecognized type: ' + str(t))
return shape
[docs]
def h3shape_to_geo(h3shape):
"""
Translate from an ``H3Shape`` to a ``__geo_interface__`` dict.
``h3shape`` should be either ``LatLngPoly`` or ``LatLngMultiPoly``
Returns
-------
dict
"""
return h3shape.__geo_interface__