Coverage for gws-app/gws/gis/zoom/__init__.py: 96%

165 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Zoom levels of maps and layers. 

2 

3A map has a list of resolutions (map units per pixel), its zoom levels. 

4Layers use a subset of the map resolutions. This package computes these 

5lists from the ``zoom`` configuration of maps and layers, from source layer 

6scale hints, and converts between scales and resolutions. 

7 

8Zoom levels can be configured in three ways: 

9 

10- as levels of the standard tile grid of the map CRS 

11 (``gws.lib.grid.for_crs``), bounded by ``minLevel`` / ``maxLevel`` or by 

12 ``minScale`` / ``maxScale``. Without explicit bounds, the map gets levels 

13 0 to ``DEFAULT_MAX_LEVEL``. 

14- as an explicit list of ``scales`` (scale denominators). 

15- with the deprecated ``resolutions``, ``initResolution``, 

16 ``minResolution`` and ``maxResolution`` options, which still work and 

17 produce a configuration warning (``warn_deprecated_options``). 

18 

19Levels are indices into the resolution list, 0 being the coarsest. Scale 

20bounds snap to the nearest available resolution. For a layer, the levels 

21are map-wide indices, and its resolutions are always taken from the map 

22resolutions. The initial map resolution is given by ``initLevel`` or 

23``initScale``, by default it is the middle of the list. 

24 

25Scales and resolutions are converted with the OGC standard pixel size. For 

26geographic CRS, resolutions are in degrees per pixel, converted with 

27``gws.lib.crs.METERS_PER_DEGREE``. Scales must be between ``MIN_SCALE`` 

28and ``MAX_SCALE``, levels between 0 and ``MAX_LEVEL``; otherwise a 

29``gws.ConfigurationError`` is raised. 

30 

31Example:: 

32 

33 map.zoom { minLevel 6 maxLevel 19 initScale 50000 } 

34 

35 map.layers+ { 

36 type "wms" 

37 provider.url "https://example.com/wms" 

38 zoom { maxScale 100000 } 

39 } 

40 

41Python usage example:: 

42 

43 resolutions = gws.gis.zoom.resolutions_from_config(cfg, crs=gws.lib.crs.WEBMERCATOR) 

44 init = gws.gis.zoom.init_resolution(cfg, resolutions, crs=gws.lib.crs.WEBMERCATOR) 

45 scale = gws.gis.zoom.res_to_scale(init, gws.lib.crs.WEBMERCATOR) 

46""" 

47 

48from typing import Optional 

49 

50import math 

51 

52import gws 

53import gws.lib.crs 

54import gws.lib.grid 

55import gws.lib.uom as units 

56 

57DEFAULT_MAX_LEVEL = 20 

58"""Default finest level for grid-based resolutions.""" 

59 

60MAX_LEVEL = 30 

61"""Highest allowed zoom level.""" 

62 

63MIN_SCALE = 1 

64"""Smallest allowed scale denominator.""" 

65 

66MAX_SCALE = 500_000_000 

67"""Largest allowed scale denominator.""" 

68 

69 

70class Config(gws.Config): 

71 """Zoom levels of a map or layer, given as scales or levels.""" 

72 

73 scales: Optional[list[float]] 

74 """Scale denominators of the zoom levels.""" 

75 initScale: Optional[float] 

76 """Scale to open the map at, snapped to the nearest zoom level.""" 

77 minScale: Optional[float] 

78 """Smallest scale denominator, snapped to the nearest zoom level.""" 

79 maxScale: Optional[float] 

80 """Largest scale denominator, snapped to the nearest zoom level.""" 

81 initLevel: Optional[int] 

82 """Zoom level to open the map at. (added in 8.5)""" 

83 minLevel: Optional[int] 

84 """Coarsest zoom level. (added in 8.5)""" 

85 maxLevel: Optional[int] 

86 """Finest zoom level. (added in 8.5)""" 

87 resolutions: Optional[list[float]] 

88 """Allowed resolutions. (deprecated in 8.5)""" 

89 initResolution: Optional[float] 

90 """Initial resolution. (deprecated in 8.5)""" 

91 minResolution: Optional[float] 

92 """Min. resolution. (deprecated in 8.5)""" 

93 maxResolution: Optional[float] 

94 """Max. resolution. (deprecated in 8.5)""" 

95 

96 

97_DEPRECATED_OPTIONS = { 

98 'resolutions': '"zoom.scales"', 

99 'initResolution': '"zoom.initScale" or "zoom.initLevel"', 

100 'minResolution': '"zoom.minScale" or "zoom.minLevel"', 

101 'maxResolution': '"zoom.maxScale" or "zoom.maxLevel"', 

102} 

103 

104 

105def warn_deprecated_options(cfg, root: gws.Root): 

106 """Register a configuration warning for each deprecated zoom option in use. 

107 

108 Args: 

109 cfg: A zoom config. 

110 root: Configuration root. 

111 """ 

112 

113 for k, v in _DEPRECATED_OPTIONS.items(): 

114 if gws.u.get(cfg, k) is not None: 

115 root.config_warning(f'"zoom.{k}" is deprecated, use {v}') 

116 

117 

118def resolutions_from_config(cfg, crs: gws.Crs = None) -> list[float]: 

119 """Compute map resolutions from a zoom config. 

120 

121 An explicit ``scales`` (or deprecated ``resolutions``) list is taken as is; 

122 otherwise the resolutions of the standard grid for the CRS are used. Level bounds 

123 are indices into the list (0 = coarsest); scale bounds snap to the nearest entry. 

124 For the grid default, the bounds drive generation, so a ``minScale`` 

125 beyond the default range extends the list. 

126 

127 Args: 

128 cfg: A zoom config. 

129 crs: CRS of the map, Web Mercator if not given. 

130 

131 Returns: 

132 A list of resolutions, sorted ascending. 

133 

134 Raises: 

135 gws.ConfigurationError: If a value is out of bounds or the result is empty. 

136 """ 

137 

138 dsc = _explicit_resolutions(cfg, crs) 

139 if dsc: 

140 dsc = sorted(set(dsc), reverse=True) 

141 lo, hi = _index_bounds(cfg, dsc, crs) 

142 dsc = dsc[lo:hi + 1] 

143 else: 

144 dsc = _ladder_resolutions(cfg, crs) 

145 

146 if not dsc: 

147 raise gws.ConfigurationError(f'empty resolutions {cfg!r}') 

148 return sorted(dsc) 

149 

150 

151def resolutions_for_layer(cfg, parent_resolutions: list[float], crs: gws.Crs = None) -> list[float]: 

152 """Compute layer resolutions from a zoom config. 

153 

154 The result is always a subset of the parent (map) resolutions: 

155 ``scales`` entries snap to the nearest parent resolution; deprecated 

156 ``resolutions`` entries must match parent resolutions; level and scale 

157 bounds select from the parent list (levels are map-wide indices). 

158 

159 Args: 

160 cfg: A zoom config. 

161 parent_resolutions: Parent (map) resolutions. 

162 crs: Map CRS, for scale conversions. 

163 

164 Returns: 

165 A list of resolutions, sorted ascending. 

166 

167 Raises: 

168 gws.ConfigurationError: If a value is invalid or the bounds select no resolutions. 

169 """ 

170 

171 pdsc = sorted(parent_resolutions, reverse=True) 

172 

173 ls = gws.u.get(cfg, 'scales') 

174 if ls: 

175 idx = sorted(set(_nearest_index(pdsc, scale_to_res(s, crs)) for s in ls)) 

176 dsc = [pdsc[i] for i in idx] 

177 else: 

178 ls = gws.u.get(cfg, 'resolutions') 

179 if ls: 

180 dsc = [pdsc[_exact_index(pdsc, r)] for r in ls] 

181 dsc = sorted(set(dsc), reverse=True) 

182 else: 

183 dsc = pdsc 

184 

185 lo, hi = _index_bounds(cfg, pdsc) 

186 dsc = [r for r in dsc if pdsc[lo] >= r >= pdsc[hi]] 

187 

188 return sorted(dsc) 

189 

190 

191def resolutions_from_source_layers(source_layers: list[gws.SourceLayer], parent_resolutions: list[float], crs: gws.Crs = None) -> list[float]: 

192 """Compute layer resolutions from source layer scale hints. 

193 

194 The union of the scale ranges of all source layers acts as scale bounds over 

195 the parent resolutions, see ``resolutions_from_scale_range``. If any source layer 

196 has no scale range, the parent resolutions are returned. 

197 

198 Args: 

199 source_layers: Source layers. 

200 parent_resolutions: Parent (map) resolutions. 

201 crs: Map CRS, for scale conversions. 

202 

203 Returns: 

204 A list of resolutions, sorted ascending. 

205 """ 

206 

207 smin = [] 

208 smax = [] 

209 

210 for sl in source_layers: 

211 sr = sl.scaleRange 

212 if not sr: 

213 return parent_resolutions 

214 smin.append(sr[0]) 

215 smax.append(sr[1]) 

216 

217 if not smin: 

218 return parent_resolutions 

219 

220 return resolutions_from_scale_range(min(smin), max(smax), parent_resolutions, crs) 

221 

222 

223def resolutions_from_scale_range(smin: float, smax: float, parent_resolutions: list[float], crs: gws.Crs = None) -> list[float]: 

224 """Compute layer resolutions from a scale range. 

225 

226 The range bounds snap to the nearest parent resolutions and select the range between. 

227 

228 Args: 

229 smin: Min. scale denominator. 

230 smax: Max. scale denominator. 

231 parent_resolutions: Parent (map) resolutions. 

232 crs: Map CRS, for scale conversions. 

233 

234 Returns: 

235 A list of resolutions, sorted ascending. Empty if the range lies outside the parent resolutions. 

236 """ 

237 

238 rmin = scale_to_res(smin, crs) 

239 rmax = scale_to_res(smax, crs) 

240 

241 pdsc = sorted(parent_resolutions, reverse=True) 

242 if rmin > pdsc[0] or rmax < pdsc[-1]: 

243 return [] 

244 

245 lo = _nearest_index(pdsc, rmax) 

246 hi = _nearest_index(pdsc, rmin) 

247 return sorted(pdsc[lo:hi + 1]) 

248 

249 

250def init_resolution(cfg, resolutions: list, crs: gws.Crs = None) -> float: 

251 """Compute the initial resolution of a map. 

252 

253 ``initLevel`` (an index, 0 = coarsest) wins over ``initScale``, which 

254 snaps to the nearest resolution; the default is the middle of the list. 

255 

256 Args: 

257 cfg: A zoom config. 

258 resolutions: Map resolutions. 

259 crs: Map CRS, for scale conversions. 

260 

261 Returns: 

262 One of the given resolutions. 

263 

264 Raises: 

265 gws.ConfigurationError: If a value is out of bounds. 

266 """ 

267 

268 dsc = sorted(resolutions, reverse=True) 

269 

270 lvl = _checked_level(cfg, 'initLevel') 

271 if lvl is not None: 

272 return dsc[min(max(lvl, 0), len(dsc) - 1)] 

273 

274 init = _res_or_scale(cfg, 'initResolution', 'initScale', crs) 

275 if not init: 

276 return dsc[len(dsc) >> 1] 

277 return min(dsc, key=lambda r: abs(init - r)) 

278 

279 

280def _ladder_resolutions(cfg, crs: gws.Crs = None) -> list[float]: 

281 mg = gws.lib.grid.for_crs(crs or gws.lib.crs.WEBMERCATOR) 

282 

283 lo = _checked_level(cfg, 'minLevel') or 0 

284 rmax = _res_or_scale(cfg, 'maxResolution', 'maxScale', crs) 

285 if rmax: 

286 lo = max(lo, _nearest_level(mg, rmax)) 

287 

288 hi = _checked_level(cfg, 'maxLevel') 

289 rmin = _res_or_scale(cfg, 'minResolution', 'minScale', crs) 

290 if rmin: 

291 z = _nearest_level(mg, rmin) 

292 hi = z if hi is None else min(hi, z) 

293 if hi is None: 

294 hi = DEFAULT_MAX_LEVEL 

295 hi = min(hi, MAX_LEVEL) 

296 

297 if lo > hi: 

298 raise gws.ConfigurationError(f'empty resolutions {cfg!r}') 

299 

300 return [gws.lib.grid.resolution_for_level(mg, z) for z in range(lo, hi + 1)] 

301 

302 

303def _index_bounds(cfg, dsc: list[float], crs: gws.Crs = None) -> tuple[int, int]: 

304 n = len(dsc) 

305 

306 lo = _checked_level(cfg, 'minLevel') or 0 

307 lo = min(max(lo, 0), n - 1) 

308 rmax = _res_or_scale(cfg, 'maxResolution', 'maxScale', crs) 

309 if rmax: 

310 lo = max(lo, _nearest_index(dsc, rmax)) 

311 

312 hi = _checked_level(cfg, 'maxLevel') 

313 hi = n - 1 if hi is None else min(max(hi, 0), n - 1) 

314 rmin = _res_or_scale(cfg, 'minResolution', 'minScale', crs) 

315 if rmin: 

316 hi = min(hi, _nearest_index(dsc, rmin)) 

317 

318 if lo > hi: 

319 raise gws.ConfigurationError(f'empty resolutions {cfg!r}') 

320 return lo, hi 

321 

322 

323def _nearest_index(dsc: list[float], res: float) -> int: 

324 return min(range(len(dsc)), key=lambda i: abs(dsc[i] - res)) 

325 

326 

327def _nearest_level(mg: gws.MapGrid, res: float) -> int: 

328 z = gws.lib.grid.level_for_resolution(mg, res) 

329 if z > 0: 

330 r1 = gws.lib.grid.resolution_for_level(mg, z - 1) 

331 r2 = gws.lib.grid.resolution_for_level(mg, z) 

332 if abs(r1 - res) < abs(r2 - res): 

333 return z - 1 

334 return z 

335 

336 

337def _exact_index(dsc: list[float], res: float) -> int: 

338 for i, r in enumerate(dsc): 

339 if math.isclose(r, res, rel_tol=1e-6): 

340 return i 

341 raise gws.ConfigurationError(f'resolution {res!r} is not a map resolution') 

342 

343 

344def _explicit_resolutions(cfg, crs: gws.Crs = None): 

345 ls = gws.u.get(cfg, 'scales') 

346 if ls: 

347 return [_checked_res(scale_to_res(x, crs), x, crs) for x in ls] 

348 ls = gws.u.get(cfg, 'resolutions') 

349 if ls: 

350 return [_checked_res(x, x, crs) for x in ls] 

351 

352 

353def _res_or_scale(cfg, r, s, crs: gws.Crs = None): 

354 x = gws.u.get(cfg, r) 

355 if x: 

356 return _checked_res(x, x, crs) 

357 x = gws.u.get(cfg, s) 

358 if x: 

359 return _checked_res(scale_to_res(x, crs), x, crs) 

360 

361 

362def _checked_level(cfg, key): 

363 v = gws.u.get(cfg, key) 

364 if v is None: 

365 return None 

366 if not (0 <= v <= MAX_LEVEL): 

367 raise gws.ConfigurationError(f'invalid {key}: {v!r}') 

368 return v 

369 

370 

371def _checked_res(res, value, crs: gws.Crs = None): 

372 if not (scale_to_res(MIN_SCALE, crs) <= res <= scale_to_res(MAX_SCALE, crs)): 

373 raise gws.ConfigurationError(f'scale/resolution out of bounds: {value!r}') 

374 return res 

375 

376 

377def scale_to_res(scale: float, crs: gws.Crs = None) -> float: 

378 """Convert a scale denominator to a resolution. 

379 

380 Args: 

381 scale: Scale denominator. 

382 crs: CRS. For a geographic CRS the result is in degrees per pixel, otherwise in meters per pixel. 

383 

384 Returns: 

385 Resolution in map units per pixel. 

386 """ 

387 

388 return units.scale_to_res(scale) / _meters_per_unit(crs) 

389 

390 

391def res_to_scale(res: float, crs: gws.Crs = None) -> int: 

392 """Convert a resolution to a scale denominator. 

393 

394 Args: 

395 res: Resolution in map units per pixel. 

396 crs: CRS. For a geographic CRS the resolution is in degrees per pixel, otherwise in meters per pixel. 

397 

398 Returns: 

399 Scale denominator. 

400 """ 

401 

402 return units.res_to_scale(res * _meters_per_unit(crs)) 

403 

404 

405def _meters_per_unit(crs: gws.Crs = None) -> float: 

406 if crs and crs.isGeographic: 

407 return gws.lib.crs.METERS_PER_DEGREE 

408 return 1.0