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

86 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +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 or none are given. 

130 """ 

131 

132 if not exts: 

133 return 

134 

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

136 

137 for ext in exts: 

138 if not intersect(res, ext): 

139 return 

140 res = ( 

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

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

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

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

145 ) 

146 return res 

147 

148 

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

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

151 

152 Args: 

153 e: An extent. 

154 

155 Returns: 

156 The center point. 

157 """ 

158 

159 return ( 

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

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

162 ) 

163 

164 

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

166 """Get the size of an extent. 

167 

168 Args: 

169 e: An extent. 

170 

171 Returns: 

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

173 """ 

174 

175 return ( 

176 e[2] - e[0], 

177 e[3] - e[1], 

178 ) 

179 

180 

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

182 """Get the width of an extent. 

183 

184 Args: 

185 e: An extent. 

186 

187 Returns: 

188 The width. 

189 """ 

190 

191 return e[2] - e[0] 

192 

193 

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

195 """Get the height of an extent. 

196 

197 Args: 

198 e: An extent. 

199 

200 Returns: 

201 The height. 

202 """ 

203 

204 return e[3] - e[1] 

205 

206 

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

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

209 

210 Args: 

211 e: An extent. 

212 

213 Returns: 

214 The diagonal length. 

215 """ 

216 

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

218 

219 

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

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

222 

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

224 so that it contains the extent at any rotation. 

225 

226 Args: 

227 e: An extent. 

228 

229 Returns: 

230 The square extent. 

231 """ 

232 

233 d = diagonal(e) 

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

235 

236 

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

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

239 

240 Args: 

241 e: An extent. 

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

243 

244 Returns: 

245 The buffered extent. 

246 """ 

247 

248 if buf == 0: 

249 return e 

250 return ( 

251 e[0] - buf, 

252 e[1] - buf, 

253 e[2] + buf, 

254 e[3] + buf, 

255 ) 

256 

257 

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

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

260 

261 Args: 

262 *exts: Extents, at least one. 

263 

264 Returns: 

265 An extent. 

266 """ 

267 

268 ext = exts[0] 

269 for e in exts: 

270 ext = ( 

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

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

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

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

275 ) 

276 return ext 

277 

278 

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

280 """Check if two extents intersect. 

281 

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

283 

284 Args: 

285 a: First extent. 

286 b: Second extent. 

287 

288 Returns: 

289 ``True`` if the extents intersect. 

290 """ 

291 

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

293 

294 

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

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

297 

298 Args: 

299 e: An extent. 

300 crs_from: Source CRS. 

301 crs_to: Target CRS. 

302 

303 Returns: 

304 The transformed extent. 

305 """ 

306 

307 return crs_from.transform_extent(e, crs_to) 

308 

309 

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

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

312 

313 Args: 

314 e: An extent in WGS84. 

315 crs_to: Target CRS. 

316 

317 Returns: 

318 The transformed extent. 

319 """ 

320 

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

322 

323 

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

325 """Transform an extent to WGS84. 

326 

327 Args: 

328 e: An extent. 

329 crs_from: Source CRS. 

330 

331 Returns: 

332 The WGS84 extent. 

333 """ 

334 

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

336 

337 

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

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

340 

341 Args: 

342 e: An extent. 

343 

344 Returns: 

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

346 """ 

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

348 

349 

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

351 """Check if an extent is valid. 

352 

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

354 

355 Args: 

356 e: An extent. 

357 

358 Returns: 

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

360 """ 

361 

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

363 return False 

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

365 return False 

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

367 return False 

368 return True 

369 

370 

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

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

373 

374 Args: 

375 e: An extent in WGS84. 

376 

377 Returns: 

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

379 """ 

380 

381 if not is_valid(e): 

382 return False 

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

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

385 

386 

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

388 if len(ls) != 4: 

389 return None 

390 try: 

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

392 except ValueError: 

393 return None 

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

395 return None 

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

397 return None 

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