Coverage for gws-app/gws/lib/extent/__init__.py: 98%

88 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""Utilities for extents. 

2 

3An extent (``gws.Extent``) is a tuple ``(x-min, y-min, x-max, y-max)`` of floats. 

4Extents have no CRS of their own; for a geo-referenced extent, see ``gws.Bounds`` and ``gws.lib.bounds``. 

5 

6This package provides functions to: 

7 

8- create extents from strings, lists, points, a center point and size, or a PostGIS ``BOX``, 

9- measure extents (center, size, width, height, diagonal), 

10- combine extents (intersection, union, buffer, circumscribed square), 

11- transform extents between CRSs, 

12- validate extents. 

13 

14Example:: 

15 

16 import gws.lib.crs 

17 import gws.lib.extent 

18 

19 ext = gws.lib.extent.from_string('0,0,100,50') 

20 gws.lib.extent.size(ext) # (100.0, 50.0) 

21 gws.lib.extent.buffer(ext, 10) # (-10.0, -10.0, 110.0, 60.0) 

22 

23 wgs = gws.lib.extent.transform_to_wgs(ext, gws.lib.crs.WEBMERCATOR) 

24""" 

25 

26from typing import Optional 

27 

28import math 

29import re 

30 

31import gws 

32import gws.lib.crs 

33 

34 

35def from_string(s: str) -> Optional[gws.Extent]: 

36 """Create an extent from a comma-separated string. 

37 

38 Args: 

39 s: String like ``x-min,y-min,x-max,y-max``. 

40 

41 Returns: 

42 An extent, or ``None`` if the string is not a valid extent. 

43 """ 

44 

45 return _from_string_list(s.split(',')) 

46 

47 

48def from_list(ls: list) -> Optional[gws.Extent]: 

49 """Create an extent from a list. 

50 

51 Args: 

52 ls: List ``[x-min, y-min, x-max, y-max]`` of numbers or numeric strings. 

53 

54 Returns: 

55 An extent, or ``None`` if the list is not a valid extent. 

56 """ 

57 

58 return _from_string_list(ls) 

59 

60 

61def from_points(a: gws.Point, b: gws.Point) -> gws.Extent: 

62 """Create an extent from two opposite corner points. 

63 

64 Args: 

65 a: First point. 

66 b: Second point. 

67 

68 Returns: 

69 The smallest extent that contains both points. 

70 """ 

71 

72 return ( 

73 min(a[0], b[0]), 

74 min(a[1], b[1]), 

75 max(a[0], b[0]), 

76 max(a[1], b[1]), 

77 ) 

78 

79 

80def from_center(xy: gws.Point, size: gws.Size) -> gws.Extent: 

81 """Create an extent of a given size around a center point. 

82 

83 Args: 

84 xy: Center point. 

85 size: Width and height. 

86 

87 Returns: 

88 An extent. 

89 """ 

90 

91 return ( 

92 xy[0] - size[0] / 2, 

93 xy[1] - size[1] / 2, 

94 xy[0] + size[0] / 2, 

95 xy[1] + size[1] / 2, 

96 ) 

97 

98 

99def from_box(box: str) -> Optional[gws.Extent]: 

100 """Create an extent from a PostGIS box. 

101 

102 Args: 

103 box: PostGIS box like ``BOX(minx miny,maxx maxy)``. 

104 

105 Returns: 

106 An extent, or ``None`` if the box is empty or invalid. 

107 """ 

108 

109 if not box: 

110 return None 

111 

112 m = re.match(r'^BOX\((.+?)\)$', str(box).upper()) 

113 if not m: 

114 return None 

115 

116 return _from_string_list(m.group(1).replace(',', ' ').split(' ')) 

117 

118 

119# 

120 

121 

122def intersection(*exts: gws.Extent) -> Optional[gws.Extent]: 

123 """Create an extent that is the intersection of all given extents. 

124 

125 Args: 

126 *exts: Extents. 

127 

128 Returns: 

129 An extent, or ``None`` if the extents do not intersect. 

130 

131 Raises: 

132 ``gws.Error``: If no extents are given. 

133 """ 

134 

135 if not exts: 

136 raise gws.Error('no extents given') 

137 

138 res = (-math.inf, -math.inf, math.inf, math.inf) 

139 

140 for ext in exts: 

141 if not intersect(res, ext): 

142 return 

143 res = ( 

144 max(res[0], ext[0]), 

145 max(res[1], ext[1]), 

146 min(res[2], ext[2]), 

147 min(res[3], ext[3]), 

148 ) 

149 return res 

150 

151 

152def center(e: gws.Extent) -> gws.Point: 

153 """Get the center point of an extent. 

154 

155 Args: 

156 e: An extent. 

157 

158 Returns: 

159 The center point. 

160 """ 

161 

162 return ( 

163 e[0] + (e[2] - e[0]) / 2, 

164 e[1] + (e[3] - e[1]) / 2, 

165 ) 

166 

167 

168def size(e: gws.Extent) -> gws.Size: 

169 """Get the size of an extent. 

170 

171 Args: 

172 e: An extent. 

173 

174 Returns: 

175 A ``(width, height)`` tuple. 

176 """ 

177 

178 return ( 

179 e[2] - e[0], 

180 e[3] - e[1], 

181 ) 

182 

183 

184def w(e: gws.Extent) -> float: 

185 """Get the width of an extent. 

186 

187 Args: 

188 e: An extent. 

189 

190 Returns: 

191 The width. 

192 """ 

193 

194 return e[2] - e[0] 

195 

196 

197def h(e: gws.Extent) -> float: 

198 """Get the height of an extent. 

199 

200 Args: 

201 e: An extent. 

202 

203 Returns: 

204 The height. 

205 """ 

206 

207 return e[3] - e[1] 

208 

209 

210def diagonal(e: gws.Extent) -> float: 

211 """Get the length of the diagonal of an extent. 

212 

213 Args: 

214 e: An extent. 

215 

216 Returns: 

217 The diagonal length. 

218 """ 

219 

220 return math.sqrt((e[2] - e[0]) ** 2 + (e[3] - e[1]) ** 2) 

221 

222 

223def circumsquare(e: gws.Extent) -> gws.Extent: 

224 """Get the square that circumscribes an extent. 

225 

226 The square has the same center as the extent, and its side equals the extent's diagonal, 

227 so that it contains the extent at any rotation. 

228 

229 Args: 

230 e: An extent. 

231 

232 Returns: 

233 The square extent. 

234 """ 

235 

236 d = diagonal(e) 

237 return from_center(center(e), (d, d)) 

238 

239 

240def buffer(e: gws.Extent, buf: float) -> gws.Extent: 

241 """Create an extent with a buffer around another extent. 

242 

243 Args: 

244 e: An extent. 

245 buf: Buffer added on each side. A positive value makes the extent bigger, a negative one smaller. 

246 

247 Returns: 

248 The buffered extent. 

249 """ 

250 

251 if buf == 0: 

252 return e 

253 return ( 

254 e[0] - buf, 

255 e[1] - buf, 

256 e[2] + buf, 

257 e[3] + buf, 

258 ) 

259 

260 

261def union(*exts: gws.Extent) -> gws.Extent: 

262 """Create the smallest extent that contains all the given extents. 

263 

264 Args: 

265 *exts: Extents, at least one. 

266 

267 Returns: 

268 An extent. 

269 

270 Raises: 

271 ``gws.Error``: If no extents are given. 

272 """ 

273 

274 if not exts: 

275 raise gws.Error('no extents given') 

276 

277 ext = exts[0] 

278 for e in exts: 

279 ext = ( 

280 min(ext[0], e[0]), 

281 min(ext[1], e[1]), 

282 max(ext[2], e[2]), 

283 max(ext[3], e[3]), 

284 ) 

285 return ext 

286 

287 

288def intersect(a: gws.Extent, b: gws.Extent) -> bool: 

289 """Check if two extents intersect. 

290 

291 Extents that only touch at the edges are considered intersecting. 

292 

293 Args: 

294 a: First extent. 

295 b: Second extent. 

296 

297 Returns: 

298 ``True`` if the extents intersect. 

299 """ 

300 

301 return a[0] <= b[2] and a[2] >= b[0] and a[1] <= b[3] and a[3] >= b[1] 

302 

303 

304def transform(e: gws.Extent, crs_from: gws.Crs, crs_to: gws.Crs) -> gws.Extent: 

305 """Transform an extent to a different coordinate reference system. 

306 

307 Args: 

308 e: An extent. 

309 crs_from: Source CRS. 

310 crs_to: Target CRS. 

311 

312 Returns: 

313 The transformed extent. 

314 """ 

315 

316 return crs_from.transform_extent(e, crs_to) 

317 

318 

319def transform_from_wgs(e: gws.Extent, crs_to: gws.Crs) -> gws.Extent: 

320 """Transform a WGS84 extent to a different coordinate reference system. 

321 

322 Args: 

323 e: An extent in WGS84. 

324 crs_to: Target CRS. 

325 

326 Returns: 

327 The transformed extent. 

328 """ 

329 

330 return gws.lib.crs.WGS84.transform_extent(e, crs_to) 

331 

332 

333def transform_to_wgs(e: gws.Extent, crs_from: gws.Crs) -> gws.Extent: 

334 """Transform an extent to WGS84. 

335 

336 Args: 

337 e: An extent. 

338 crs_from: Source CRS. 

339 

340 Returns: 

341 The WGS84 extent. 

342 """ 

343 

344 return crs_from.transform_extent(e, gws.lib.crs.WGS84) 

345 

346 

347def swap_xy(e: gws.Extent) -> gws.Extent: 

348 """Swap the x and y values of an extent. 

349 

350 Args: 

351 e: An extent. 

352 

353 Returns: 

354 The extent ``(y-min, x-min, y-max, x-max)``. 

355 """ 

356 return e[1], e[0], e[3], e[2] 

357 

358 

359def is_valid(e: gws.Extent) -> bool: 

360 """Check if an extent is valid. 

361 

362 A valid extent has four finite values, and its minimum values are less than its maximum values. 

363 

364 Args: 

365 e: An extent. 

366 

367 Returns: 

368 ``True`` if the extent is valid. 

369 """ 

370 

371 if not e or len(e) != 4: 

372 return False 

373 if not all(math.isfinite(p) for p in e): 

374 return False 

375 if e[0] >= e[2] or e[1] >= e[3]: 

376 return False 

377 return True 

378 

379 

380def is_valid_wgs(e: gws.Extent) -> bool: 

381 """Check if an extent is valid and lies within the WGS84 bounds. 

382 

383 Args: 

384 e: An extent in WGS84. 

385 

386 Returns: 

387 ``True`` if the extent is valid and within ``(-180, -90, 180, 90)``. 

388 """ 

389 

390 if not is_valid(e): 

391 return False 

392 w = gws.lib.crs.WGS84.extent 

393 return e[0] >= w[0] and e[1] >= w[1] and e[2] <= w[2] and e[3] <= w[3] 

394 

395 

396def _from_string_list(ls: list) -> Optional[gws.Extent]: 

397 if len(ls) != 4: 

398 return None 

399 try: 

400 e = [float(p) for p in ls] 

401 except ValueError: 

402 return None 

403 if not all(math.isfinite(p) for p in e): 

404 return None 

405 if e[0] >= e[2] or e[1] >= e[3]: 

406 return None 

407 return e[0], e[1], e[2], e[3]