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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Zoom levels of maps and layers.
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.
8Zoom levels can be configured in three ways:
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``).
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.
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.
31Example::
33 map.zoom { minLevel 6 maxLevel 19 initScale 50000 }
35 map.layers+ {
36 type "wms"
37 provider.url "https://example.com/wms"
38 zoom { maxScale 100000 }
39 }
41Python usage example::
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"""
48from typing import Optional
50import math
52import gws
53import gws.lib.crs
54import gws.lib.grid
55import gws.lib.uom as units
57DEFAULT_MAX_LEVEL = 20
58"""Default finest level for grid-based resolutions."""
60MAX_LEVEL = 30
61"""Highest allowed zoom level."""
63MIN_SCALE = 1
64"""Smallest allowed scale denominator."""
66MAX_SCALE = 500_000_000
67"""Largest allowed scale denominator."""
70class Config(gws.Config):
71 """Zoom levels of a map or layer, given as scales or levels."""
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)"""
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}
105def warn_deprecated_options(cfg, root: gws.Root):
106 """Register a configuration warning for each deprecated zoom option in use.
108 Args:
109 cfg: A zoom config.
110 root: Configuration root.
111 """
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}')
118def resolutions_from_config(cfg, crs: gws.Crs = None) -> list[float]:
119 """Compute map resolutions from a zoom config.
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.
127 Args:
128 cfg: A zoom config.
129 crs: CRS of the map, Web Mercator if not given.
131 Returns:
132 A list of resolutions, sorted ascending.
134 Raises:
135 gws.ConfigurationError: If a value is out of bounds or the result is empty.
136 """
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)
146 if not dsc:
147 raise gws.ConfigurationError(f'empty resolutions {cfg!r}')
148 return sorted(dsc)
151def resolutions_for_layer(cfg, parent_resolutions: list[float], crs: gws.Crs = None) -> list[float]:
152 """Compute layer resolutions from a zoom config.
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).
159 Args:
160 cfg: A zoom config.
161 parent_resolutions: Parent (map) resolutions.
162 crs: Map CRS, for scale conversions.
164 Returns:
165 A list of resolutions, sorted ascending.
167 Raises:
168 gws.ConfigurationError: If a value is invalid or the bounds select no resolutions.
169 """
171 pdsc = sorted(parent_resolutions, reverse=True)
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
185 lo, hi = _index_bounds(cfg, pdsc)
186 dsc = [r for r in dsc if pdsc[lo] >= r >= pdsc[hi]]
188 return sorted(dsc)
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.
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.
198 Args:
199 source_layers: Source layers.
200 parent_resolutions: Parent (map) resolutions.
201 crs: Map CRS, for scale conversions.
203 Returns:
204 A list of resolutions, sorted ascending.
205 """
207 smin = []
208 smax = []
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])
217 if not smin:
218 return parent_resolutions
220 return resolutions_from_scale_range(min(smin), max(smax), parent_resolutions, crs)
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.
226 The range bounds snap to the nearest parent resolutions and select the range between.
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.
234 Returns:
235 A list of resolutions, sorted ascending. Empty if the range lies outside the parent resolutions.
236 """
238 rmin = scale_to_res(smin, crs)
239 rmax = scale_to_res(smax, crs)
241 pdsc = sorted(parent_resolutions, reverse=True)
242 if rmin > pdsc[0] or rmax < pdsc[-1]:
243 return []
245 lo = _nearest_index(pdsc, rmax)
246 hi = _nearest_index(pdsc, rmin)
247 return sorted(pdsc[lo:hi + 1])
250def init_resolution(cfg, resolutions: list, crs: gws.Crs = None) -> float:
251 """Compute the initial resolution of a map.
253 ``initLevel`` (an index, 0 = coarsest) wins over ``initScale``, which
254 snaps to the nearest resolution; the default is the middle of the list.
256 Args:
257 cfg: A zoom config.
258 resolutions: Map resolutions.
259 crs: Map CRS, for scale conversions.
261 Returns:
262 One of the given resolutions.
264 Raises:
265 gws.ConfigurationError: If a value is out of bounds.
266 """
268 dsc = sorted(resolutions, reverse=True)
270 lvl = _checked_level(cfg, 'initLevel')
271 if lvl is not None:
272 return dsc[min(max(lvl, 0), len(dsc) - 1)]
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))
280def _ladder_resolutions(cfg, crs: gws.Crs = None) -> list[float]:
281 mg = gws.lib.grid.for_crs(crs or gws.lib.crs.WEBMERCATOR)
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))
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)
297 if lo > hi:
298 raise gws.ConfigurationError(f'empty resolutions {cfg!r}')
300 return [gws.lib.grid.resolution_for_level(mg, z) for z in range(lo, hi + 1)]
303def _index_bounds(cfg, dsc: list[float], crs: gws.Crs = None) -> tuple[int, int]:
304 n = len(dsc)
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))
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))
318 if lo > hi:
319 raise gws.ConfigurationError(f'empty resolutions {cfg!r}')
320 return lo, hi
323def _nearest_index(dsc: list[float], res: float) -> int:
324 return min(range(len(dsc)), key=lambda i: abs(dsc[i] - res))
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
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')
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]
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)
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
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
377def scale_to_res(scale: float, crs: gws.Crs = None) -> float:
378 """Convert a scale denominator to a resolution.
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.
384 Returns:
385 Resolution in map units per pixel.
386 """
388 return units.scale_to_res(scale) / _meters_per_unit(crs)
391def res_to_scale(res: float, crs: gws.Crs = None) -> int:
392 """Convert a resolution to a scale denominator.
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.
398 Returns:
399 Scale denominator.
400 """
402 return units.res_to_scale(res * _meters_per_unit(crs))
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