bboxkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bboxkit/__init__.py +31 -0
- bboxkit/__main__.py +6 -0
- bboxkit/_geo.py +222 -0
- bboxkit/_version.py +1 -0
- bboxkit/bbox.py +1029 -0
- bboxkit/cli.py +311 -0
- bboxkit/crs.py +380 -0
- bboxkit/formats.py +86 -0
- bboxkit/geohash.py +83 -0
- bboxkit/mgrs.py +136 -0
- bboxkit/nominatim.py +113 -0
- bboxkit/parsing.py +722 -0
- bboxkit/pluscode.py +125 -0
- bboxkit/py.typed +0 -0
- bboxkit/tiles.py +148 -0
- bboxkit-0.1.0.dist-info/METADATA +270 -0
- bboxkit-0.1.0.dist-info/RECORD +21 -0
- bboxkit-0.1.0.dist-info/WHEEL +5 -0
- bboxkit-0.1.0.dist-info/entry_points.txt +2 -0
- bboxkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- bboxkit-0.1.0.dist-info/top_level.txt +1 -0
bboxkit/bbox.py
ADDED
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
"""The :class:`BBox` value type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import math
|
|
7
|
+
from typing import (
|
|
8
|
+
Any,
|
|
9
|
+
Callable,
|
|
10
|
+
Dict,
|
|
11
|
+
Iterable,
|
|
12
|
+
Iterator,
|
|
13
|
+
List,
|
|
14
|
+
Optional,
|
|
15
|
+
Sequence,
|
|
16
|
+
Tuple,
|
|
17
|
+
Union,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from . import geohash as _geohash
|
|
21
|
+
from . import mgrs as _mgrs
|
|
22
|
+
from . import pluscode as _pluscode
|
|
23
|
+
from . import tiles as _tiles
|
|
24
|
+
from ._geo import (
|
|
25
|
+
MERCATOR_MAX_LAT,
|
|
26
|
+
Arc,
|
|
27
|
+
arc_to_lons,
|
|
28
|
+
covering_arc,
|
|
29
|
+
lon_arc,
|
|
30
|
+
meridian_distance,
|
|
31
|
+
metres_to_dlat,
|
|
32
|
+
metres_to_dlon,
|
|
33
|
+
parallel_radius,
|
|
34
|
+
rectangle_area_m2,
|
|
35
|
+
snap_arc_edges,
|
|
36
|
+
unwrapped_to_lons,
|
|
37
|
+
wrap_delta,
|
|
38
|
+
wrap_lon,
|
|
39
|
+
)
|
|
40
|
+
from .crs import (
|
|
41
|
+
WEB_MERCATOR,
|
|
42
|
+
WGS84,
|
|
43
|
+
CRSLike,
|
|
44
|
+
_from_lonlat_fn,
|
|
45
|
+
_pyproj_transformer,
|
|
46
|
+
_to_lonlat_fn,
|
|
47
|
+
is_builtin,
|
|
48
|
+
is_geographic,
|
|
49
|
+
lonlat_to_mercator,
|
|
50
|
+
mercator_to_lonlat,
|
|
51
|
+
normalize_crs,
|
|
52
|
+
utm_crs,
|
|
53
|
+
utm_zone,
|
|
54
|
+
)
|
|
55
|
+
from .tiles import Tile
|
|
56
|
+
|
|
57
|
+
__all__ = ["BBox", "union_all", "Number"]
|
|
58
|
+
|
|
59
|
+
Number = Union[int, float]
|
|
60
|
+
PointLike = Sequence[float]
|
|
61
|
+
|
|
62
|
+
_EPS = 1e-12
|
|
63
|
+
#: Upper bound on the number of boxes or tiles a single call may create.
|
|
64
|
+
DEFAULT_MAX_ITEMS = 1_000_000
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@functools.lru_cache(maxsize=512)
|
|
68
|
+
def _crs_info(crs: CRSLike) -> Tuple[str, bool]:
|
|
69
|
+
c = normalize_crs(crs)
|
|
70
|
+
return (c, is_geographic(c))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _num(value: float, precision: Optional[int] = None) -> str:
|
|
74
|
+
"""Compact, exact (round-trippable) decimal text for a coordinate."""
|
|
75
|
+
if precision is not None:
|
|
76
|
+
value = round(value, precision)
|
|
77
|
+
if value == 0:
|
|
78
|
+
return "0"
|
|
79
|
+
text = repr(float(value))
|
|
80
|
+
if "e" in text or "E" in text:
|
|
81
|
+
text = f"{value:.17f}".rstrip("0").rstrip(".")
|
|
82
|
+
if text.endswith(".0"):
|
|
83
|
+
text = text[:-2]
|
|
84
|
+
return text
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _coerce(name: str, value: Any) -> float:
|
|
88
|
+
if isinstance(value, (str, bytes, bool)) or value is None:
|
|
89
|
+
raise TypeError(f"{name} must be a number, got {value!r}")
|
|
90
|
+
try:
|
|
91
|
+
f = float(value)
|
|
92
|
+
except (TypeError, ValueError) as exc:
|
|
93
|
+
raise TypeError(f"{name} must be a number, got {value!r}") from exc
|
|
94
|
+
if not math.isfinite(f):
|
|
95
|
+
raise ValueError(f"{name} must be finite, got {value!r}")
|
|
96
|
+
return f + 0.0 # turns -0.0 into 0.0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class BBox:
|
|
100
|
+
"""An immutable, hashable bounding box ``(west, south, east, north, crs)``.
|
|
101
|
+
|
|
102
|
+
For geographic CRSs (longitude/latitude degrees, ``EPSG:4326`` by default)
|
|
103
|
+
``west > east`` means the box crosses the antimeridian, exactly as in
|
|
104
|
+
RFC 7946. ``BBox(-180, s, 180, n)`` spans every longitude. Coordinates are
|
|
105
|
+
validated on construction and the object cannot be modified afterwards;
|
|
106
|
+
use :meth:`replace` to derive a new box.
|
|
107
|
+
|
|
108
|
+
``==`` and ``hash`` are exact (so boxes work as dict keys and in sets);
|
|
109
|
+
use :meth:`equals` for a tolerance-based comparison.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
__slots__ = ("west", "south", "east", "north", "crs", "_geographic")
|
|
113
|
+
|
|
114
|
+
west: float
|
|
115
|
+
south: float
|
|
116
|
+
east: float
|
|
117
|
+
north: float
|
|
118
|
+
crs: str
|
|
119
|
+
_geographic: bool
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
west: Number,
|
|
124
|
+
south: Number,
|
|
125
|
+
east: Number,
|
|
126
|
+
north: Number,
|
|
127
|
+
crs: CRSLike = WGS84,
|
|
128
|
+
) -> None:
|
|
129
|
+
c, geographic = _crs_info(crs)
|
|
130
|
+
w = _coerce("west", west)
|
|
131
|
+
s = _coerce("south", south)
|
|
132
|
+
e = _coerce("east", east)
|
|
133
|
+
n = _coerce("north", north)
|
|
134
|
+
if s > n:
|
|
135
|
+
raise ValueError(f"south ({s}) is greater than north ({n}); check the coordinate order")
|
|
136
|
+
if geographic:
|
|
137
|
+
if not (-90.0 <= s <= 90.0 and -90.0 <= n <= 90.0):
|
|
138
|
+
raise ValueError(f"latitudes must be within [-90, 90], got south={s}, north={n}")
|
|
139
|
+
if not (-180.0 <= w <= 180.0 and -180.0 <= e <= 180.0):
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"longitudes must be within [-180, 180], got west={w}, east={e}"
|
|
142
|
+
" (use BBox.wrap() for unwrapped longitudes)"
|
|
143
|
+
)
|
|
144
|
+
# Canonical spelling of the antimeridian edges.
|
|
145
|
+
if w == 180.0 and e < 180.0:
|
|
146
|
+
w = -180.0
|
|
147
|
+
if e == -180.0 and w > -180.0:
|
|
148
|
+
e = 180.0
|
|
149
|
+
elif w > e:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"west ({w}) is greater than east ({e}); only geographic boxes may cross"
|
|
152
|
+
" the antimeridian"
|
|
153
|
+
)
|
|
154
|
+
for name, value in (
|
|
155
|
+
("west", w),
|
|
156
|
+
("south", s),
|
|
157
|
+
("east", e),
|
|
158
|
+
("north", n),
|
|
159
|
+
("crs", c),
|
|
160
|
+
("_geographic", geographic),
|
|
161
|
+
):
|
|
162
|
+
object.__setattr__(self, name, value)
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# Value-type plumbing
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
169
|
+
raise AttributeError("BBox is immutable; use replace()")
|
|
170
|
+
|
|
171
|
+
def __delattr__(self, name: str) -> None:
|
|
172
|
+
raise AttributeError("BBox is immutable")
|
|
173
|
+
|
|
174
|
+
def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]:
|
|
175
|
+
return (BBox, (self.west, self.south, self.east, self.north, self.crs))
|
|
176
|
+
|
|
177
|
+
def _key(self) -> Tuple[float, float, float, float, str]:
|
|
178
|
+
return (self.west, self.south, self.east, self.north, self.crs)
|
|
179
|
+
|
|
180
|
+
def __eq__(self, other: object) -> bool:
|
|
181
|
+
if not isinstance(other, BBox):
|
|
182
|
+
return NotImplemented
|
|
183
|
+
return self._key() == other._key()
|
|
184
|
+
|
|
185
|
+
def __hash__(self) -> int:
|
|
186
|
+
return hash(self._key())
|
|
187
|
+
|
|
188
|
+
def __repr__(self) -> str:
|
|
189
|
+
suffix = "" if self.crs == WGS84 else f", crs={self.crs!r}"
|
|
190
|
+
return f"BBox({self.west!r}, {self.south!r}, {self.east!r}, {self.north!r}{suffix})"
|
|
191
|
+
|
|
192
|
+
def __str__(self) -> str:
|
|
193
|
+
return self.to_string()
|
|
194
|
+
|
|
195
|
+
def __iter__(self) -> Iterator[float]:
|
|
196
|
+
"""Iterate ``west, south, east, north`` (so ``w, s, e, n = box`` works)."""
|
|
197
|
+
return iter((self.west, self.south, self.east, self.north))
|
|
198
|
+
|
|
199
|
+
def __round__(self, ndigits: Optional[int] = None) -> BBox:
|
|
200
|
+
return self.round(ndigits)
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def __geo_interface__(self) -> Dict[str, Any]:
|
|
204
|
+
"""GeoJSON geometry, so ``shapely.geometry.shape(box)`` just works."""
|
|
205
|
+
return self.to_geojson()
|
|
206
|
+
|
|
207
|
+
def replace(self, **changes: Any) -> BBox:
|
|
208
|
+
"""Return a copy with some of ``west/south/east/north/crs`` replaced."""
|
|
209
|
+
values: Dict[str, Any] = {
|
|
210
|
+
"west": self.west,
|
|
211
|
+
"south": self.south,
|
|
212
|
+
"east": self.east,
|
|
213
|
+
"north": self.north,
|
|
214
|
+
"crs": self.crs,
|
|
215
|
+
}
|
|
216
|
+
unknown = set(changes) - set(values)
|
|
217
|
+
if unknown:
|
|
218
|
+
raise TypeError(f"unknown field(s): {', '.join(sorted(unknown))}")
|
|
219
|
+
values.update(changes)
|
|
220
|
+
return BBox(**values)
|
|
221
|
+
|
|
222
|
+
def equals(self, other: BBox, tol: float = 1e-9) -> bool:
|
|
223
|
+
"""Equality within ``tol`` (CRS units); longitudes compare modulo 360."""
|
|
224
|
+
if not isinstance(other, BBox):
|
|
225
|
+
return False
|
|
226
|
+
if self.crs != other.crs:
|
|
227
|
+
return False
|
|
228
|
+
if abs(self.south - other.south) > tol or abs(self.north - other.north) > tol:
|
|
229
|
+
return False
|
|
230
|
+
if self._geographic:
|
|
231
|
+
if self.is_full_longitude and other.is_full_longitude:
|
|
232
|
+
return True
|
|
233
|
+
return (
|
|
234
|
+
abs(self.width - other.width) <= 2 * tol
|
|
235
|
+
and abs(wrap_delta(self.west - other.west)) <= tol
|
|
236
|
+
and abs(wrap_delta(self.east - other.east)) <= tol
|
|
237
|
+
)
|
|
238
|
+
return abs(self.west - other.west) <= tol and abs(self.east - other.east) <= tol
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
# Construction helpers
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def parse(cls, value: Any, **kwargs: Any) -> BBox:
|
|
246
|
+
"""Parse any supported representation; see :func:`bboxkit.parse`."""
|
|
247
|
+
from .parsing import parse
|
|
248
|
+
|
|
249
|
+
return parse(value, **kwargs)
|
|
250
|
+
|
|
251
|
+
@classmethod
|
|
252
|
+
def from_bounds(cls, bounds: Sequence[Number], crs: CRSLike = WGS84) -> BBox:
|
|
253
|
+
"""From a ``(minx, miny, maxx, maxy)`` tuple, e.g. Shapely ``geom.bounds``."""
|
|
254
|
+
if len(bounds) != 4:
|
|
255
|
+
raise ValueError(f"expected 4 numbers, got {len(bounds)}")
|
|
256
|
+
return cls(bounds[0], bounds[1], bounds[2], bounds[3], crs=crs)
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def wrap(
|
|
260
|
+
cls, west: Number, south: Number, east: Number, north: Number, crs: CRSLike = WGS84
|
|
261
|
+
) -> BBox:
|
|
262
|
+
"""Build a geographic box from unwrapped longitudes (e.g. ``170 .. 190``).
|
|
263
|
+
|
|
264
|
+
``east - west >= 360`` gives the full longitude range; otherwise both
|
|
265
|
+
longitudes are wrapped into [-180, 180], which may produce a box that
|
|
266
|
+
crosses the antimeridian.
|
|
267
|
+
"""
|
|
268
|
+
w, e = float(west), float(east)
|
|
269
|
+
if e < w:
|
|
270
|
+
raise ValueError("wrap() expects west <= east in unwrapped longitudes")
|
|
271
|
+
lo, hi = unwrapped_to_lons(w, e)
|
|
272
|
+
return cls(lo, south, hi, north, crs=crs)
|
|
273
|
+
|
|
274
|
+
@classmethod
|
|
275
|
+
def world(cls) -> BBox:
|
|
276
|
+
"""The whole globe, ``(-180, -90, 180, 90)``."""
|
|
277
|
+
return cls(-180.0, -90.0, 180.0, 90.0)
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def from_point(cls, lon: Number, lat: Number, crs: CRSLike = WGS84) -> BBox:
|
|
281
|
+
"""A zero-size box at a point; combine with :meth:`buffer`."""
|
|
282
|
+
return cls(lon, lat, lon, lat, crs=crs)
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
# Basic properties
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def is_geographic(self) -> bool:
|
|
290
|
+
return self._geographic
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def crosses_antimeridian(self) -> bool:
|
|
294
|
+
return self._geographic and self.west > self.east
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def is_full_longitude(self) -> bool:
|
|
298
|
+
return self._geographic and self.west == -180.0 and self.east == 180.0
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def bounds(self) -> Tuple[float, float, float, float]:
|
|
302
|
+
"""``(west, south, east, north)`` as a plain tuple."""
|
|
303
|
+
return (self.west, self.south, self.east, self.north)
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def width(self) -> float:
|
|
307
|
+
"""East-west extent in CRS units (degrees across the antimeridian if needed)."""
|
|
308
|
+
if self._geographic:
|
|
309
|
+
return self._arc()[1]
|
|
310
|
+
return self.east - self.west
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def height(self) -> float:
|
|
314
|
+
return self.north - self.south
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def center(self) -> Tuple[float, float]:
|
|
318
|
+
"""Midpoint ``(x, y)``; the longitude midpoint follows the antimeridian."""
|
|
319
|
+
if self._geographic:
|
|
320
|
+
start, width = self._arc()
|
|
321
|
+
mid = start + width / 2.0
|
|
322
|
+
lon = mid if mid <= 180.0 else mid - 360.0
|
|
323
|
+
return (lon, (self.south + self.north) / 2.0)
|
|
324
|
+
return ((self.west + self.east) / 2.0, (self.south + self.north) / 2.0)
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def width_m(self) -> float:
|
|
328
|
+
"""Ground width along the central parallel, in metres (CRS units if projected)."""
|
|
329
|
+
if self._geographic:
|
|
330
|
+
return parallel_radius(self.center[1]) * math.radians(self.width)
|
|
331
|
+
return self.width
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def height_m(self) -> float:
|
|
335
|
+
"""Ground height along a meridian, in metres (CRS units if projected)."""
|
|
336
|
+
if self._geographic:
|
|
337
|
+
return meridian_distance(self.north) - meridian_distance(self.south)
|
|
338
|
+
return self.height
|
|
339
|
+
|
|
340
|
+
def _arc(self) -> Arc:
|
|
341
|
+
return lon_arc(self.west, self.east)
|
|
342
|
+
|
|
343
|
+
def _same_crs(self, other: BBox) -> None:
|
|
344
|
+
if not isinstance(other, BBox):
|
|
345
|
+
raise TypeError(f"expected a BBox, got {type(other).__name__}")
|
|
346
|
+
if other.crs != self.crs:
|
|
347
|
+
raise ValueError(
|
|
348
|
+
f"CRS mismatch: {self.crs} vs {other.crs}; convert one with to_crs() first"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def _from_lon_arc(self, arc: Arc, south: float, north: float) -> BBox:
|
|
352
|
+
w, e = arc_to_lons(arc)
|
|
353
|
+
return BBox(w, south, e, north, crs=self.crs)
|
|
354
|
+
|
|
355
|
+
def parts(self) -> Tuple[BBox, ...]:
|
|
356
|
+
"""Split at the antimeridian: one box, or two if it crosses."""
|
|
357
|
+
if not self.crosses_antimeridian:
|
|
358
|
+
return (self,)
|
|
359
|
+
return (
|
|
360
|
+
BBox(self.west, self.south, 180.0, self.north, crs=self.crs),
|
|
361
|
+
BBox(-180.0, self.south, self.east, self.north, crs=self.crs),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
split_antimeridian = parts
|
|
365
|
+
|
|
366
|
+
# ------------------------------------------------------------------
|
|
367
|
+
# Predicates and set operations
|
|
368
|
+
# ------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
def contains(self, other: Union[BBox, PointLike]) -> bool:
|
|
371
|
+
"""True if ``other`` (a box or an ``(x, y)`` point) lies inside, edges included."""
|
|
372
|
+
if not isinstance(other, BBox):
|
|
373
|
+
x, y = float(other[0]), float(other[1])
|
|
374
|
+
if not self.south - _EPS <= y <= self.north + _EPS:
|
|
375
|
+
return False
|
|
376
|
+
if self._geographic:
|
|
377
|
+
start, width = self._arc()
|
|
378
|
+
return (x - start) % 360.0 <= width + _EPS or width >= 360.0
|
|
379
|
+
return self.west - _EPS <= x <= self.east + _EPS
|
|
380
|
+
self._same_crs(other)
|
|
381
|
+
if other.south < self.south - _EPS or other.north > self.north + _EPS:
|
|
382
|
+
return False
|
|
383
|
+
if not self._geographic:
|
|
384
|
+
return self.west - _EPS <= other.west and other.east <= self.east + _EPS
|
|
385
|
+
s_start, s_width = self._arc()
|
|
386
|
+
o_start, o_width = other._arc()
|
|
387
|
+
if s_width >= 360.0:
|
|
388
|
+
return True
|
|
389
|
+
if o_width > s_width + _EPS:
|
|
390
|
+
return False
|
|
391
|
+
offset = (o_start - s_start) % 360.0
|
|
392
|
+
if offset > 360.0 - _EPS:
|
|
393
|
+
offset = 0.0
|
|
394
|
+
return offset + o_width <= s_width + 1e-9
|
|
395
|
+
|
|
396
|
+
def __contains__(self, item: Union[BBox, PointLike]) -> bool:
|
|
397
|
+
return self.contains(item)
|
|
398
|
+
|
|
399
|
+
def intersects(self, other: BBox) -> bool:
|
|
400
|
+
"""True if the boxes share at least one point (touching counts)."""
|
|
401
|
+
self._same_crs(other)
|
|
402
|
+
if other.south > self.north or other.north < self.south:
|
|
403
|
+
return False
|
|
404
|
+
if not self._geographic:
|
|
405
|
+
return other.west <= self.east and other.east >= self.west
|
|
406
|
+
return bool(self._lon_overlaps(other))
|
|
407
|
+
|
|
408
|
+
def _lon_overlaps(self, other: BBox) -> List[Tuple[float, float]]:
|
|
409
|
+
s_start, s_width = self._arc()
|
|
410
|
+
o_start, o_width = other._arc()
|
|
411
|
+
if s_width >= 360.0:
|
|
412
|
+
return [(o_start, o_start + o_width)]
|
|
413
|
+
if o_width >= 360.0:
|
|
414
|
+
return [(s_start, s_start + s_width)]
|
|
415
|
+
lo_s, hi_s = s_start, s_start + s_width
|
|
416
|
+
out: List[Tuple[float, float]] = []
|
|
417
|
+
for k in (-1, 0, 1):
|
|
418
|
+
lo_o = o_start + 360.0 * k
|
|
419
|
+
hi_o = lo_o + o_width
|
|
420
|
+
lo, hi = max(lo_s, lo_o), min(hi_s, hi_o)
|
|
421
|
+
if lo <= hi and (lo, hi) not in out:
|
|
422
|
+
out.append((lo, hi))
|
|
423
|
+
return out
|
|
424
|
+
|
|
425
|
+
def intersection_parts(self, other: BBox) -> List[BBox]:
|
|
426
|
+
"""All pieces of the intersection (two when wide boxes overlap at both ends)."""
|
|
427
|
+
self._same_crs(other)
|
|
428
|
+
s, n = max(self.south, other.south), min(self.north, other.north)
|
|
429
|
+
if s > n:
|
|
430
|
+
return []
|
|
431
|
+
if not self._geographic:
|
|
432
|
+
w, e = max(self.west, other.west), min(self.east, other.east)
|
|
433
|
+
return [BBox(w, s, e, n, crs=self.crs)] if w <= e else []
|
|
434
|
+
return [
|
|
435
|
+
self._from_lon_arc((wrap_lon(lo), hi - lo), s, n)
|
|
436
|
+
for lo, hi in self._lon_overlaps(other)
|
|
437
|
+
]
|
|
438
|
+
|
|
439
|
+
def intersection(self, other: BBox) -> Optional[BBox]:
|
|
440
|
+
"""The common box, or ``None`` if the boxes do not meet.
|
|
441
|
+
|
|
442
|
+
Raises ``ValueError`` in the rare case where the overlap is two separate
|
|
443
|
+
pieces (see :meth:`intersection_parts`).
|
|
444
|
+
"""
|
|
445
|
+
pieces = self.intersection_parts(other)
|
|
446
|
+
if not pieces:
|
|
447
|
+
return None
|
|
448
|
+
if len(pieces) > 1:
|
|
449
|
+
raise ValueError(
|
|
450
|
+
"the intersection consists of two separate boxes; use intersection_parts()"
|
|
451
|
+
)
|
|
452
|
+
return pieces[0]
|
|
453
|
+
|
|
454
|
+
def __and__(self, other: BBox) -> Optional[BBox]:
|
|
455
|
+
return self.intersection(other)
|
|
456
|
+
|
|
457
|
+
def union(self, *others: BBox) -> BBox:
|
|
458
|
+
"""Smallest box containing this box and ``others`` (antimeridian-aware)."""
|
|
459
|
+
return union_all((self,) + others)
|
|
460
|
+
|
|
461
|
+
def __or__(self, other: BBox) -> BBox:
|
|
462
|
+
return self.union(other)
|
|
463
|
+
|
|
464
|
+
# ------------------------------------------------------------------
|
|
465
|
+
# Measurements and transformations
|
|
466
|
+
# ------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
def area_km2(self) -> float:
|
|
469
|
+
"""Area in square kilometres.
|
|
470
|
+
|
|
471
|
+
Geographic boxes use the exact area of a lat/lon rectangle on the WGS 84
|
|
472
|
+
ellipsoid. Web Mercator boxes are converted to lon/lat first. Other
|
|
473
|
+
projected boxes return ``width * height / 1e6`` in CRS units (metres for
|
|
474
|
+
UTM, i.e. grid area).
|
|
475
|
+
"""
|
|
476
|
+
if self._geographic:
|
|
477
|
+
return rectangle_area_m2(self.width, self.south, self.north) / 1e6
|
|
478
|
+
if self.crs == WEB_MERCATOR:
|
|
479
|
+
return self.to_crs(WGS84).area_km2()
|
|
480
|
+
return self.width * self.height / 1e6
|
|
481
|
+
|
|
482
|
+
def buffer(self, distance: float) -> BBox:
|
|
483
|
+
"""Grow (or shrink, if negative) the box by ``distance`` metres on every side.
|
|
484
|
+
|
|
485
|
+
Geographic boxes use the local radii of curvature of WGS 84 so that every
|
|
486
|
+
point within ``distance`` of the box ends up inside it; the longitude
|
|
487
|
+
margin is taken at the most poleward latitude. Boxes reaching a pole get
|
|
488
|
+
the full longitude range. Projected boxes are buffered in CRS units.
|
|
489
|
+
"""
|
|
490
|
+
d = float(distance)
|
|
491
|
+
if not math.isfinite(d):
|
|
492
|
+
raise ValueError("distance must be finite")
|
|
493
|
+
if not self._geographic:
|
|
494
|
+
w, s, e, n = self.west - d, self.south - d, self.east + d, self.north + d
|
|
495
|
+
if w > e or s > n:
|
|
496
|
+
raise ValueError("negative buffer collapses the box")
|
|
497
|
+
return BBox(w, s, e, n, crs=self.crs)
|
|
498
|
+
if d >= 0:
|
|
499
|
+
s = max(-90.0, self.south - metres_to_dlat(d, self.south))
|
|
500
|
+
n = min(90.0, self.north + metres_to_dlat(d, self.north))
|
|
501
|
+
pole_lat = max(abs(s), abs(n))
|
|
502
|
+
if pole_lat >= 90.0 - 1e-9 or self.is_full_longitude:
|
|
503
|
+
return BBox(-180.0, s, 180.0, n, crs=self.crs)
|
|
504
|
+
dlon = metres_to_dlon(d, pole_lat)
|
|
505
|
+
start, width = self._arc()
|
|
506
|
+
if width + 2 * dlon >= 360.0:
|
|
507
|
+
return BBox(-180.0, s, 180.0, n, crs=self.crs)
|
|
508
|
+
return self._from_lon_arc((wrap_lon(start - dlon), width + 2 * dlon), s, n)
|
|
509
|
+
m = -d
|
|
510
|
+
s = self.south + metres_to_dlat(m, self.south)
|
|
511
|
+
n = self.north - metres_to_dlat(m, self.north)
|
|
512
|
+
if s > n:
|
|
513
|
+
raise ValueError("negative buffer collapses the box")
|
|
514
|
+
equator_lat = 0.0 if s <= 0.0 <= n else min(abs(s), abs(n))
|
|
515
|
+
dlon = metres_to_dlon(m, equator_lat)
|
|
516
|
+
start, width = self._arc()
|
|
517
|
+
if 2 * dlon > width:
|
|
518
|
+
raise ValueError("negative buffer collapses the box")
|
|
519
|
+
return self._from_lon_arc((wrap_lon(start + dlon), width - 2 * dlon), s, n)
|
|
520
|
+
|
|
521
|
+
def round(self, ndigits: Optional[int] = None) -> BBox:
|
|
522
|
+
"""Round every coordinate (``round(box, 4)`` works too)."""
|
|
523
|
+
vals = [float(round(v, ndigits)) for v in self]
|
|
524
|
+
return BBox(vals[0], vals[1], vals[2], vals[3], crs=self.crs)
|
|
525
|
+
|
|
526
|
+
def snap(self, step: float, outward: bool = True) -> BBox:
|
|
527
|
+
"""Snap the edges to multiples of ``step`` (outward keeps the box covered)."""
|
|
528
|
+
if not step > 0:
|
|
529
|
+
raise ValueError("step must be positive")
|
|
530
|
+
lo_fn: Callable[[float], float]
|
|
531
|
+
hi_fn: Callable[[float], float]
|
|
532
|
+
if outward:
|
|
533
|
+
|
|
534
|
+
def lo_fn(v: float) -> float:
|
|
535
|
+
return math.floor(v / step + 1e-9) * step
|
|
536
|
+
|
|
537
|
+
def hi_fn(v: float) -> float:
|
|
538
|
+
return math.ceil(v / step - 1e-9) * step
|
|
539
|
+
else:
|
|
540
|
+
|
|
541
|
+
def lo_fn(v: float) -> float:
|
|
542
|
+
return round(v / step) * step
|
|
543
|
+
|
|
544
|
+
hi_fn = lo_fn
|
|
545
|
+
|
|
546
|
+
def clean(v: float) -> float:
|
|
547
|
+
return float(round(v, 12))
|
|
548
|
+
|
|
549
|
+
s, n = clean(lo_fn(self.south)), clean(hi_fn(self.north))
|
|
550
|
+
if not self._geographic:
|
|
551
|
+
return BBox(clean(lo_fn(self.west)), s, clean(hi_fn(self.east)), n, crs=self.crs)
|
|
552
|
+
s, n = max(-90.0, s), min(90.0, n)
|
|
553
|
+
if s > n:
|
|
554
|
+
s = n = clean(lo_fn((self.south + self.north) / 2.0))
|
|
555
|
+
start, width = self._arc()
|
|
556
|
+
lo, hi = clean(lo_fn(start)), clean(hi_fn(start + width))
|
|
557
|
+
if hi < lo:
|
|
558
|
+
hi = lo
|
|
559
|
+
w, e = (clean(v) for v in unwrapped_to_lons(lo, hi))
|
|
560
|
+
return BBox(max(-180.0, w), s, min(180.0, e), n, crs=self.crs)
|
|
561
|
+
|
|
562
|
+
def expand_to_aspect(self, ratio: float, mode: str = "mercator") -> BBox:
|
|
563
|
+
"""Grow the box (never shrink) about its centre to ``width / height == ratio``.
|
|
564
|
+
|
|
565
|
+
``mode`` picks the space the ratio is measured in: ``"mercator"`` (what a
|
|
566
|
+
web map shows), ``"ground"`` (metres on the ground) or ``"degrees"``.
|
|
567
|
+
Projected boxes always use CRS units.
|
|
568
|
+
"""
|
|
569
|
+
if not ratio > 0 or not math.isfinite(ratio):
|
|
570
|
+
raise ValueError("ratio must be a positive number")
|
|
571
|
+
if not self._geographic:
|
|
572
|
+
w, s, e, n = _expand(self.west, self.south, self.east, self.north, ratio)
|
|
573
|
+
return BBox(w, s, e, n, crs=self.crs)
|
|
574
|
+
start, width = self._arc()
|
|
575
|
+
if mode == "degrees":
|
|
576
|
+
w, s, e, n = _expand(start, self.south, start + width, self.north, ratio)
|
|
577
|
+
return BBox.wrap(w, max(-90.0, s), e, min(90.0, n), crs=self.crs)
|
|
578
|
+
if mode == "mercator":
|
|
579
|
+
x0, y0 = lonlat_to_mercator(start, self.south)
|
|
580
|
+
x1, y1 = lonlat_to_mercator(start + width, self.north)
|
|
581
|
+
mx0, my0, mx1, my1 = _expand(x0, y0, x1, y1, ratio)
|
|
582
|
+
w, s = mercator_to_lonlat(mx0, my0)
|
|
583
|
+
e, n = mercator_to_lonlat(mx1, my1)
|
|
584
|
+
# Latitudes beyond the Mercator limit are kept, never cut.
|
|
585
|
+
s, n = min(s, self.south), max(n, self.north)
|
|
586
|
+
return BBox.wrap(w, s, e, n, crs=self.crs)
|
|
587
|
+
if mode == "ground":
|
|
588
|
+
w_m, h_m = self.width_m, self.height_m
|
|
589
|
+
if h_m <= 0 and w_m <= 0:
|
|
590
|
+
return self
|
|
591
|
+
if h_m <= 0 or w_m / h_m > ratio:
|
|
592
|
+
grow = (w_m / ratio - h_m) / 2.0
|
|
593
|
+
s = max(-90.0, self.south - metres_to_dlat(grow, self.south))
|
|
594
|
+
n = min(90.0, self.north + metres_to_dlat(grow, self.north))
|
|
595
|
+
return BBox(self.west, s, self.east, n, crs=self.crs)
|
|
596
|
+
grow_deg = metres_to_dlon((h_m * ratio - w_m) / 2.0, self.center[1])
|
|
597
|
+
return BBox.wrap(
|
|
598
|
+
start - grow_deg, self.south, start + width + grow_deg, self.north, crs=self.crs
|
|
599
|
+
)
|
|
600
|
+
raise ValueError("mode must be 'mercator', 'ground' or 'degrees'")
|
|
601
|
+
|
|
602
|
+
# ------------------------------------------------------------------
|
|
603
|
+
# Grids and tiles
|
|
604
|
+
# ------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
def grid(self, nx: int, ny: int) -> List[BBox]:
|
|
607
|
+
"""Split into ``nx`` columns by ``ny`` rows.
|
|
608
|
+
|
|
609
|
+
Cells are returned row by row from the north-west corner, like map
|
|
610
|
+
tiles. Cells may cross the antimeridian when the box does.
|
|
611
|
+
"""
|
|
612
|
+
if int(nx) != nx or int(ny) != ny or nx < 1 or ny < 1:
|
|
613
|
+
raise ValueError("nx and ny must be positive integers")
|
|
614
|
+
nx, ny = int(nx), int(ny)
|
|
615
|
+
if nx * ny > DEFAULT_MAX_ITEMS:
|
|
616
|
+
raise ValueError(f"grid of {nx * ny} cells exceeds {DEFAULT_MAX_ITEMS}")
|
|
617
|
+
start = self._arc()[0] if self._geographic else self.west
|
|
618
|
+
return self._cells(start, self.width / nx, nx, self.height / ny, ny, even=True)
|
|
619
|
+
|
|
620
|
+
def _cells(
|
|
621
|
+
self, x0: float, dx: float, nx: int, dy: float, ny: int, even: bool = False
|
|
622
|
+
) -> List[BBox]:
|
|
623
|
+
x_end = x0 + self.width
|
|
624
|
+
east_u = self._unwrapped_east() if self._geographic else self.east
|
|
625
|
+
|
|
626
|
+
def x_at(i: int) -> float:
|
|
627
|
+
if even: # interpolate between the exact edges: 29 .. 29.2 halves at 29.1
|
|
628
|
+
return (self.west * (nx - i) + east_u * i) / nx
|
|
629
|
+
return min(x_end, x0 + i * dx)
|
|
630
|
+
|
|
631
|
+
def y_at(j: int) -> float:
|
|
632
|
+
if even:
|
|
633
|
+
return (self.north * (ny - j) + self.south * j) / ny
|
|
634
|
+
return max(self.south, self.north - j * dy)
|
|
635
|
+
|
|
636
|
+
# Column edges; the outer ones are the box's own (exact) edges.
|
|
637
|
+
edges = [self.west]
|
|
638
|
+
for i in range(1, nx):
|
|
639
|
+
x = x_at(i)
|
|
640
|
+
edges.append(x - 360.0 if self._geographic and x > 180.0 else x)
|
|
641
|
+
edges.append(self.east)
|
|
642
|
+
rows = [self.north] + [y_at(j) for j in range(1, ny)] + [self.south]
|
|
643
|
+
out: List[BBox] = []
|
|
644
|
+
for j in range(ny):
|
|
645
|
+
n, s = rows[j], rows[j + 1]
|
|
646
|
+
for i in range(nx):
|
|
647
|
+
w, e = edges[i], edges[i + 1]
|
|
648
|
+
if self._geographic and e == -180.0 and i + 1 < nx:
|
|
649
|
+
e = 180.0 # an inner edge on the antimeridian closes the cell at +180
|
|
650
|
+
out.append(BBox(w, s, e, n, crs=self.crs))
|
|
651
|
+
return out
|
|
652
|
+
|
|
653
|
+
def split(
|
|
654
|
+
self,
|
|
655
|
+
cell: float,
|
|
656
|
+
cell_y: Optional[float] = None,
|
|
657
|
+
max_cells: int = DEFAULT_MAX_ITEMS,
|
|
658
|
+
) -> List[BBox]:
|
|
659
|
+
"""Split into cells of ``cell`` x ``cell_y`` metres (square if ``cell_y`` is None).
|
|
660
|
+
|
|
661
|
+
Geographic boxes get a regular lon/lat grid whose cells measure the
|
|
662
|
+
requested size at the box's central latitude. The last column and row
|
|
663
|
+
are clipped to the box. Order is row by row from the north-west.
|
|
664
|
+
Projected boxes use CRS units.
|
|
665
|
+
"""
|
|
666
|
+
cx = float(cell)
|
|
667
|
+
cy = cx if cell_y is None else float(cell_y)
|
|
668
|
+
if not (cx > 0 and cy > 0):
|
|
669
|
+
raise ValueError("cell size must be positive")
|
|
670
|
+
if self._geographic:
|
|
671
|
+
lat = self.center[1]
|
|
672
|
+
dx = metres_to_dlon(cx, lat)
|
|
673
|
+
dy = metres_to_dlat(cy, lat)
|
|
674
|
+
else:
|
|
675
|
+
dx, dy = cx, cy
|
|
676
|
+
nx = max(1, math.ceil(self.width / dx - 1e-9)) if self.width > 0 else 1
|
|
677
|
+
ny = max(1, math.ceil(self.height / dy - 1e-9)) if self.height > 0 else 1
|
|
678
|
+
if nx * ny > max_cells:
|
|
679
|
+
raise ValueError(f"split would create {nx * ny} cells (max_cells={max_cells})")
|
|
680
|
+
start = self._arc()[0] if self._geographic else self.west
|
|
681
|
+
return self._cells(start, dx, nx, dy, ny)
|
|
682
|
+
|
|
683
|
+
def _wgs84(self) -> BBox:
|
|
684
|
+
return self if self.crs == WGS84 else self.to_crs(WGS84)
|
|
685
|
+
|
|
686
|
+
def tile_count(self, zoom: int) -> int:
|
|
687
|
+
"""Number of XYZ tiles at ``zoom`` overlapping the box."""
|
|
688
|
+
b = self._wgs84()
|
|
689
|
+
return sum(
|
|
690
|
+
(x1 - x0 + 1) * (y1 - y0 + 1)
|
|
691
|
+
for x0, x1, y0, y1 in _tiles.tile_ranges(b.west, b.south, b.east, b.north, zoom)
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
def tiles(self, zoom: int, max_tiles: int = DEFAULT_MAX_ITEMS) -> List[Tile]:
|
|
695
|
+
"""XYZ tiles at ``zoom`` overlapping the box (latitudes clamped to +-85.05)."""
|
|
696
|
+
count = self.tile_count(zoom)
|
|
697
|
+
if count > max_tiles:
|
|
698
|
+
raise ValueError(f"{count} tiles at zoom {zoom} exceed max_tiles={max_tiles}")
|
|
699
|
+
b = self._wgs84()
|
|
700
|
+
return list(_tiles.tiles_covering(b.west, b.south, b.east, b.north, zoom))
|
|
701
|
+
|
|
702
|
+
def iter_tiles(self, zoom: int) -> Iterator[Tile]:
|
|
703
|
+
"""Lazily yield the tiles of :meth:`tiles` without a size limit."""
|
|
704
|
+
b = self._wgs84()
|
|
705
|
+
return _tiles.tiles_covering(b.west, b.south, b.east, b.north, zoom)
|
|
706
|
+
|
|
707
|
+
# ------------------------------------------------------------------
|
|
708
|
+
# Reprojection
|
|
709
|
+
# ------------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
def _edge_points(self, densify: int) -> List[Tuple[float, float]]:
|
|
712
|
+
steps = max(1, int(densify) + 1)
|
|
713
|
+
x0 = self._arc()[0] if self._geographic else self.west
|
|
714
|
+
x1 = x0 + self.width
|
|
715
|
+
pts: List[Tuple[float, float]] = []
|
|
716
|
+
for i in range(steps + 1):
|
|
717
|
+
t = i / steps
|
|
718
|
+
x = x0 + (x1 - x0) * t
|
|
719
|
+
y = self.south + (self.north - self.south) * t
|
|
720
|
+
pts.extend(((x, self.south), (x, self.north), (x0, y), (x1, y)))
|
|
721
|
+
return pts
|
|
722
|
+
|
|
723
|
+
def to_crs(self, crs: CRSLike, densify: int = 21) -> BBox:
|
|
724
|
+
"""Reproject by transforming ``densify`` points along every edge.
|
|
725
|
+
|
|
726
|
+
WGS 84, Web Mercator and UTM are built in; any other CRS needs pyproj.
|
|
727
|
+
A box crossing the antimeridian keeps contiguous x values in Web
|
|
728
|
+
Mercator (east may exceed 20037508.34 m) and becomes a crossing box
|
|
729
|
+
again when converted back to longitude/latitude.
|
|
730
|
+
"""
|
|
731
|
+
dst = normalize_crs(crs)
|
|
732
|
+
if dst == self.crs:
|
|
733
|
+
return self
|
|
734
|
+
if not (is_builtin(self.crs) and is_builtin(dst)):
|
|
735
|
+
t = _pyproj_transformer(self.crs, dst)
|
|
736
|
+
w, s, e, n = t.transform_bounds(
|
|
737
|
+
self.west, self.south, self.east, self.north, densify_pts=densify
|
|
738
|
+
)
|
|
739
|
+
if _crs_info(dst)[1]:
|
|
740
|
+
return BBox(max(-180.0, w), max(-90.0, s), min(180.0, e), min(90.0, n), crs=dst)
|
|
741
|
+
return BBox(w, s, e, n, crs=dst)
|
|
742
|
+
to_ll = _to_lonlat_fn(self.crs)
|
|
743
|
+
lonlat = [to_ll(x, y) for x, y in self._edge_points(densify)]
|
|
744
|
+
if dst == WGS84:
|
|
745
|
+
lons = [p[0] for p in lonlat]
|
|
746
|
+
lats = [p[1] for p in lonlat]
|
|
747
|
+
w, e = unwrapped_to_lons(min(lons), max(lons))
|
|
748
|
+
return BBox(w, max(-90.0, min(lats)), e, min(90.0, max(lats)), crs=dst)
|
|
749
|
+
from_ll = _from_lonlat_fn(dst)
|
|
750
|
+
xy = [from_ll(lon, lat) for lon, lat in lonlat]
|
|
751
|
+
xs = [p[0] for p in xy]
|
|
752
|
+
ys = [p[1] for p in xy]
|
|
753
|
+
return BBox(min(xs), min(ys), max(xs), max(ys), crs=dst)
|
|
754
|
+
|
|
755
|
+
def to_utm(self, zone: Optional[int] = None, south: Optional[bool] = None) -> BBox:
|
|
756
|
+
"""Reproject to WGS 84 / UTM (zone and hemisphere default to the centre's)."""
|
|
757
|
+
b = self._wgs84()
|
|
758
|
+
lon, lat = b.center
|
|
759
|
+
z = utm_zone(lon, lat) if zone is None else zone
|
|
760
|
+
hemi_south = (lat < 0) if south is None else south
|
|
761
|
+
return self.to_crs(utm_crs(z, hemi_south))
|
|
762
|
+
|
|
763
|
+
# ------------------------------------------------------------------
|
|
764
|
+
# Formatting
|
|
765
|
+
# ------------------------------------------------------------------
|
|
766
|
+
|
|
767
|
+
def to_tuple(self) -> Tuple[float, float, float, float]:
|
|
768
|
+
"""``(west, south, east, north)``."""
|
|
769
|
+
return self.bounds
|
|
770
|
+
|
|
771
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
772
|
+
return {
|
|
773
|
+
"west": self.west,
|
|
774
|
+
"south": self.south,
|
|
775
|
+
"east": self.east,
|
|
776
|
+
"north": self.north,
|
|
777
|
+
"crs": self.crs,
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
def to_string(
|
|
781
|
+
self, order: str = "wsen", sep: str = ",", precision: Optional[int] = None
|
|
782
|
+
) -> str:
|
|
783
|
+
"""Four numbers in ``order`` (``"wsen"`` or ``"swne"``)."""
|
|
784
|
+
key = order.lower()
|
|
785
|
+
if key not in ("wsen", "swne"):
|
|
786
|
+
raise ValueError("order must be 'wsen' or 'swne'")
|
|
787
|
+
vals = self.bounds if key == "wsen" else (self.south, self.west, self.north, self.east)
|
|
788
|
+
return sep.join(_num(v, precision) for v in vals)
|
|
789
|
+
|
|
790
|
+
def to_wsen(self, precision: Optional[int] = None) -> str:
|
|
791
|
+
"""``"west,south,east,north"`` (GeoJSON, OSM API, Shapely, STAC order)."""
|
|
792
|
+
return self.to_string("wsen", precision=precision)
|
|
793
|
+
|
|
794
|
+
def to_swne(self, precision: Optional[int] = None) -> str:
|
|
795
|
+
"""``"south,west,north,east"`` (Overpass, Leaflet, bboxfinder URL order)."""
|
|
796
|
+
return self.to_string("swne", precision=precision)
|
|
797
|
+
|
|
798
|
+
def to_bboxfinder(self, precision: Optional[int] = None) -> str:
|
|
799
|
+
"""bboxfinder.com link (its URL fragment is ``south,west,north,east``)."""
|
|
800
|
+
return "http://bboxfinder.com/#" + self._wgs84().to_swne(precision)
|
|
801
|
+
|
|
802
|
+
def to_overpass(self, setting: bool = False, precision: Optional[int] = None) -> str:
|
|
803
|
+
"""Overpass QL bbox filter ``(s,w,n,e)`` or global ``[bbox:s,w,n,e]`` setting."""
|
|
804
|
+
text = self._wgs84().to_swne(precision)
|
|
805
|
+
return f"[bbox:{text}]" if setting else f"({text})"
|
|
806
|
+
|
|
807
|
+
def to_osm_url(self, viewport: Tuple[int, int] = (1024, 768)) -> str:
|
|
808
|
+
"""openstreetmap.org link whose ``#map=zoom/lat/lon`` view fits the box."""
|
|
809
|
+
b = self._wgs84()
|
|
810
|
+
vw, vh = viewport
|
|
811
|
+
if vw <= 0 or vh <= 0:
|
|
812
|
+
raise ValueError("viewport must be positive")
|
|
813
|
+
start, width = b._arc()
|
|
814
|
+
x0, y0 = lonlat_to_mercator(start, b.south)
|
|
815
|
+
x1, y1 = lonlat_to_mercator(start + width, b.north)
|
|
816
|
+
world = 2 * math.pi * 6378137.0
|
|
817
|
+
frac_w = max((x1 - x0) / world, 1e-12)
|
|
818
|
+
frac_h = max((y1 - y0) / world, 1e-12)
|
|
819
|
+
zoom = math.floor(math.log2(min(vw / (256 * frac_w), vh / (256 * frac_h))))
|
|
820
|
+
zoom = max(0, min(19, zoom))
|
|
821
|
+
lon, _ = mercator_to_lonlat((x0 + x1) / 2.0, 0.0)
|
|
822
|
+
_, lat = mercator_to_lonlat(0.0, (y0 + y1) / 2.0)
|
|
823
|
+
lon = wrap_lon(lon) if lon >= 180.0 else lon
|
|
824
|
+
return f"https://www.openstreetmap.org/#map={zoom}/{lat:.5f}/{lon:.5f}"
|
|
825
|
+
|
|
826
|
+
def _ring(self, w: float, e: float, precision: Optional[int]) -> str:
|
|
827
|
+
s, n = self.south, self.north
|
|
828
|
+
pts = ((w, s), (e, s), (e, n), (w, n), (w, s))
|
|
829
|
+
return "(" + ", ".join(f"{_num(x, precision)} {_num(y, precision)}" for x, y in pts) + ")"
|
|
830
|
+
|
|
831
|
+
def to_wkt(self, precision: Optional[int] = None) -> str:
|
|
832
|
+
"""WKT ``POLYGON`` (``MULTIPOLYGON`` split at the antimeridian if crossing)."""
|
|
833
|
+
parts = self.parts()
|
|
834
|
+
if len(parts) == 1:
|
|
835
|
+
return f"POLYGON ({self._ring(self.west, self.east, precision)})"
|
|
836
|
+
rings = ", ".join(f"({p._ring(p.west, p.east, precision)})" for p in parts)
|
|
837
|
+
return f"MULTIPOLYGON ({rings})"
|
|
838
|
+
|
|
839
|
+
def to_wkt_envelope(self, precision: Optional[int] = None) -> str:
|
|
840
|
+
"""CQL / Solr ``ENVELOPE(minX, maxX, maxY, minY)``."""
|
|
841
|
+
p = precision
|
|
842
|
+
return (
|
|
843
|
+
f"ENVELOPE({_num(self.west, p)}, {_num(self.east, p)}, "
|
|
844
|
+
f"{_num(self.north, p)}, {_num(self.south, p)})"
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
def _require_plain(self, what: str) -> None:
|
|
848
|
+
if self.crosses_antimeridian:
|
|
849
|
+
raise ValueError(f"{what} cannot express an antimeridian-crossing box; use parts()")
|
|
850
|
+
|
|
851
|
+
def _epsg(self) -> Optional[int]:
|
|
852
|
+
if self.crs.startswith("EPSG:") and self.crs[5:].isdigit():
|
|
853
|
+
return int(self.crs[5:])
|
|
854
|
+
return None
|
|
855
|
+
|
|
856
|
+
def to_postgis(self, precision: Optional[int] = None) -> str:
|
|
857
|
+
"""PostGIS ``ST_MakeEnvelope(xmin, ymin, xmax, ymax, srid)``."""
|
|
858
|
+
self._require_plain("ST_MakeEnvelope")
|
|
859
|
+
srid = self._epsg()
|
|
860
|
+
nums = ", ".join(_num(v, precision) for v in self)
|
|
861
|
+
return f"ST_MakeEnvelope({nums}, {srid if srid is not None else 0})"
|
|
862
|
+
|
|
863
|
+
def to_geojson(
|
|
864
|
+
self, feature: bool = False, properties: Optional[Dict[str, Any]] = None
|
|
865
|
+
) -> Dict[str, Any]:
|
|
866
|
+
"""GeoJSON geometry (or Feature) with an RFC 7946 ``bbox`` member.
|
|
867
|
+
|
|
868
|
+
Crossing boxes become a ``MultiPolygon`` split at the antimeridian, as
|
|
869
|
+
RFC 7946 section 3.1.9 recommends; the ``bbox`` keeps ``west > east``.
|
|
870
|
+
Non-WGS 84 boxes are reprojected first (GeoJSON is always WGS 84).
|
|
871
|
+
"""
|
|
872
|
+
b = self._wgs84()
|
|
873
|
+
|
|
874
|
+
def ring(p: BBox) -> List[List[float]]:
|
|
875
|
+
w, s, e, n = p.bounds
|
|
876
|
+
return [[w, s], [e, s], [e, n], [w, n], [w, s]]
|
|
877
|
+
|
|
878
|
+
parts = b.parts()
|
|
879
|
+
geom: Dict[str, Any]
|
|
880
|
+
if len(parts) == 1:
|
|
881
|
+
geom = {"type": "Polygon", "coordinates": [ring(b)]}
|
|
882
|
+
else:
|
|
883
|
+
geom = {"type": "MultiPolygon", "coordinates": [[ring(p)] for p in parts]}
|
|
884
|
+
geom["bbox"] = list(b.bounds)
|
|
885
|
+
if not feature:
|
|
886
|
+
return geom
|
|
887
|
+
bbox = geom.pop("bbox")
|
|
888
|
+
return {
|
|
889
|
+
"type": "Feature",
|
|
890
|
+
"bbox": bbox,
|
|
891
|
+
"properties": dict(properties or {}),
|
|
892
|
+
"geometry": geom,
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
def to_shapely_bounds(self) -> Tuple[float, float, float, float]:
|
|
896
|
+
"""``(minx, miny, maxx, maxy)`` as Shapely's ``geom.bounds`` returns it."""
|
|
897
|
+
self._require_plain("a Shapely bounds tuple")
|
|
898
|
+
return self.bounds
|
|
899
|
+
|
|
900
|
+
def to_gdal_te(self, with_srs: bool = False, precision: Optional[int] = None) -> str:
|
|
901
|
+
"""gdalwarp ``-te xmin ymin xmax ymax`` (optionally with ``-te_srs``)."""
|
|
902
|
+
self._require_plain("gdalwarp -te")
|
|
903
|
+
text = "-te " + " ".join(_num(v, precision) for v in self)
|
|
904
|
+
return text + f" -te_srs {self.crs}" if with_srs else text
|
|
905
|
+
|
|
906
|
+
def to_gdal_projwin(self, with_srs: bool = False, precision: Optional[int] = None) -> str:
|
|
907
|
+
"""gdal_translate ``-projwin ulx uly lrx lry`` (optionally with ``-projwin_srs``)."""
|
|
908
|
+
self._require_plain("gdal_translate -projwin")
|
|
909
|
+
vals = (self.west, self.north, self.east, self.south)
|
|
910
|
+
text = "-projwin " + " ".join(_num(v, precision) for v in vals)
|
|
911
|
+
return text + f" -projwin_srs {self.crs}" if with_srs else text
|
|
912
|
+
|
|
913
|
+
def _unwrapped_east(self) -> float:
|
|
914
|
+
return self.east + 360.0 if self.crosses_antimeridian else self.east
|
|
915
|
+
|
|
916
|
+
def to_leaflet(self) -> List[List[float]]:
|
|
917
|
+
"""Leaflet ``LatLngBounds`` array ``[[south, west], [north, east]]``.
|
|
918
|
+
|
|
919
|
+
Crossing boxes use ``east + 360`` so the map shows one contiguous box.
|
|
920
|
+
"""
|
|
921
|
+
b = self._wgs84()
|
|
922
|
+
return [[b.south, b.west], [b.north, b._unwrapped_east()]]
|
|
923
|
+
|
|
924
|
+
def to_mapbox(self) -> List[List[float]]:
|
|
925
|
+
"""Mapbox / MapLibre ``LngLatBounds`` array ``[[west, south], [east, north]]``."""
|
|
926
|
+
b = self._wgs84()
|
|
927
|
+
return [[b.west, b.south], [b._unwrapped_east(), b.north]]
|
|
928
|
+
|
|
929
|
+
def _deepest(self, levels: Iterable[Any], cell_of: Callable[[Any], BBox]) -> Any:
|
|
930
|
+
best = None
|
|
931
|
+
for level in levels:
|
|
932
|
+
cell = cell_of(level)
|
|
933
|
+
if not cell.contains(self):
|
|
934
|
+
break
|
|
935
|
+
best = level
|
|
936
|
+
return best
|
|
937
|
+
|
|
938
|
+
def to_tile(self, max_zoom: int = _tiles.MAX_ZOOM) -> Tile:
|
|
939
|
+
"""Deepest XYZ tile containing the whole box (latitudes clamped to +-85.05)."""
|
|
940
|
+
b = self._wgs84()
|
|
941
|
+
clamped = b.replace(
|
|
942
|
+
south=max(-MERCATOR_MAX_LAT, min(MERCATOR_MAX_LAT, b.south)),
|
|
943
|
+
north=max(-MERCATOR_MAX_LAT, min(MERCATOR_MAX_LAT, b.north)),
|
|
944
|
+
)
|
|
945
|
+
lon, lat = clamped.center
|
|
946
|
+
|
|
947
|
+
def cell(z: int) -> BBox:
|
|
948
|
+
return BBox(*_tiles.lonlat_to_tile(lon, lat, z).bounds)
|
|
949
|
+
|
|
950
|
+
z = clamped._deepest(range(max_zoom + 1), cell)
|
|
951
|
+
if z is None: # only if latitudes reach the clamp exactly; z0 is the world
|
|
952
|
+
z = 0
|
|
953
|
+
return _tiles.lonlat_to_tile(lon, lat, z)
|
|
954
|
+
|
|
955
|
+
def to_quadkey(self, max_zoom: int = _tiles.MAX_ZOOM) -> str:
|
|
956
|
+
"""Quadkey of :meth:`to_tile` (``""`` when only the zoom-0 tile fits)."""
|
|
957
|
+
return self.to_tile(max_zoom).quadkey
|
|
958
|
+
|
|
959
|
+
def to_geohash(self, max_precision: int = _geohash.MAX_PRECISION) -> str:
|
|
960
|
+
"""Longest geohash whose cell contains the whole box."""
|
|
961
|
+
b = self._wgs84()
|
|
962
|
+
lon, lat = b.center
|
|
963
|
+
p = b._deepest(
|
|
964
|
+
range(1, max_precision + 1),
|
|
965
|
+
lambda k: BBox(*_geohash.decode_bounds(_geohash.encode(lon, lat, k))),
|
|
966
|
+
)
|
|
967
|
+
if p is None:
|
|
968
|
+
raise ValueError(
|
|
969
|
+
"the box is larger than any geohash cell (straddles a 45 x 45 deg cell edge)"
|
|
970
|
+
)
|
|
971
|
+
return _geohash.encode(lon, lat, p)
|
|
972
|
+
|
|
973
|
+
def to_pluscode(self) -> str:
|
|
974
|
+
"""Longest Plus Code whose area contains the whole box."""
|
|
975
|
+
b = self._wgs84()
|
|
976
|
+
lon, lat = b.center
|
|
977
|
+
length = b._deepest(
|
|
978
|
+
_pluscode.VALID_LENGTHS,
|
|
979
|
+
lambda k: BBox(*_pluscode.decode_bounds(_pluscode.encode(lon, lat, k))),
|
|
980
|
+
)
|
|
981
|
+
if length is None:
|
|
982
|
+
raise ValueError("the box is larger than any Plus Code area (20 x 20 deg)")
|
|
983
|
+
return _pluscode.encode(lon, lat, length)
|
|
984
|
+
|
|
985
|
+
def to_mgrs(self) -> str:
|
|
986
|
+
"""Finest MGRS square (100 km down to 1 m) containing the whole box."""
|
|
987
|
+
b = self._wgs84()
|
|
988
|
+
lon, lat = b.center
|
|
989
|
+
p = b._deepest(range(0, 6), lambda k: BBox(*_mgrs.square_bounds(_mgrs.encode(lon, lat, k))))
|
|
990
|
+
if p is None:
|
|
991
|
+
raise ValueError("the box does not fit in a single MGRS 100 km square")
|
|
992
|
+
return _mgrs.encode(lon, lat, p)
|
|
993
|
+
|
|
994
|
+
def format(self, fmt: str, **kwargs: Any) -> str:
|
|
995
|
+
"""Format by name (see :data:`bboxkit.FORMATS`); lists and dicts become JSON."""
|
|
996
|
+
from .formats import format_bbox
|
|
997
|
+
|
|
998
|
+
return format_bbox(self, fmt, **kwargs)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def union_all(boxes: Iterable[BBox]) -> BBox:
|
|
1002
|
+
"""Smallest box containing every box (RFC 7946 minimal-longitude-gap rule)."""
|
|
1003
|
+
items = list(boxes)
|
|
1004
|
+
if not items:
|
|
1005
|
+
raise ValueError("union_all() needs at least one box")
|
|
1006
|
+
first = items[0]
|
|
1007
|
+
for b in items[1:]:
|
|
1008
|
+
first._same_crs(b)
|
|
1009
|
+
s = min(b.south for b in items)
|
|
1010
|
+
n = max(b.north for b in items)
|
|
1011
|
+
if not first.is_geographic:
|
|
1012
|
+
return BBox(min(b.west for b in items), s, max(b.east for b in items), n, crs=first.crs)
|
|
1013
|
+
arc = covering_arc(b._arc() for b in items)
|
|
1014
|
+
w, e = snap_arc_edges(arc, [b.west for b in items], [b.east for b in items])
|
|
1015
|
+
return BBox(w, s, e, n, crs=first.crs)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _expand(
|
|
1019
|
+
w: float, s: float, e: float, n: float, ratio: float
|
|
1020
|
+
) -> Tuple[float, float, float, float]:
|
|
1021
|
+
"""Grow ``(w, s, e, n)`` about its centre to ``width / height == ratio``."""
|
|
1022
|
+
width, height = e - w, n - s
|
|
1023
|
+
if height == 0 and width == 0:
|
|
1024
|
+
return (w, s, e, n)
|
|
1025
|
+
if height == 0 or width / height > ratio:
|
|
1026
|
+
grow = (width / ratio - height) / 2.0
|
|
1027
|
+
return (w, s - grow, e, n + grow)
|
|
1028
|
+
grow = (height * ratio - width) / 2.0
|
|
1029
|
+
return (w - grow, s, e + grow, n)
|