Coverage for gws-app/gws/base/layer/core.py: 77%
309 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 22:59 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 22:59 +0200
1"""Base layer object."""
3from typing import Optional, cast
5import gws
6import gws.base.model
7import gws.config.util
8import gws.lib.bounds
9import gws.lib.crs
10import gws.lib.extent
11import gws.gis.source
12import gws.gis.zoom
13import gws.base.metadata
14import gws.lib.mime
15import gws.lib.image
17from . import ows
19DEFAULT_TILE_SIZE = 256
22class CacheConfig(gws.Config):
23 """Cache configuration"""
25 maxAge: gws.Duration = '7d'
26 """Cache max. age."""
27 maxLevel: int = 1
28 """Max. zoom level to cache."""
29 requestBuffer: Optional[int]
30 """Pixel buffer for tile requests."""
31 requestTiles: Optional[int]
32 """Number of tiles to request at once."""
35class GridConfig(gws.Config):
36 """Grid configuration for caches and tiled data"""
38 crs: Optional[gws.CrsName]
39 """Target CRS for the grid."""
40 extent: Optional[gws.Extent]
41 """Target extent for the grid."""
42 origin: Optional[gws.Origin]
43 """Grid origin, defaults to north-west."""
44 resolutions: Optional[list[float]]
45 """Grid resolutions, defaults to parent layer resolutions."""
46 tileSize: Optional[int]
47 """Tile size in pixels, defaults to 256."""
50class AutoLayersOptions(gws.ConfigWithAccess):
51 """Configuration for automatic layers."""
53 applyTo: Optional[gws.gis.source.LayerFilter]
54 """Source layers to apply the configuration to."""
55 config: dict
56 """Configuration for the matching layers."""
59class ClientOptions(gws.Data):
60 """Client options for a layer."""
62 expanded: bool = False
63 """The layer is expanded in the list view."""
64 unlisted: bool = False
65 """The layer is hidden in the list view."""
66 selected: bool = False
67 """The layer is initially selected."""
68 hidden: bool = False
69 """The layer is initially hidden."""
70 unfolded: bool = False
71 """The layer is not listed, but its children are."""
72 exclusive: bool = False
73 """Only one of this layer children is visible at a time."""
74 treeClassName = ''
75 """CSS class name for the layer tree item."""
78class GridProps(gws.Props):
79 origin: str
80 extent: gws.Extent
81 resolutions: list[float]
82 tileSize: int
85class Config(gws.ConfigWithAccess):
86 """Layer configuration"""
88 cache: Optional[CacheConfig]
89 """Cache configuration."""
90 clientOptions: Optional[ClientOptions]
91 """Options for the layer display in the client."""
92 cssSelector: str = ''
93 """Css selector for feature layers."""
94 display: gws.LayerDisplayMode = gws.LayerDisplayMode.box
95 """Layer display mode."""
96 extent: Optional[gws.Extent]
97 """Layer extent."""
98 zoomExtent: Optional[gws.Extent]
99 """Layer zoom extent."""
100 extentBuffer: Optional[int]
101 """Extent buffer."""
102 finders: Optional[list[gws.ext.config.finder]]
103 """Search providers."""
104 grid: Optional[GridConfig]
105 """Client grid."""
106 imageFormat: Optional[gws.lib.image.FormatConfig]
107 """Image format."""
108 legend: Optional[gws.ext.config.legend]
109 """Legend configuration."""
110 loadingStrategy: gws.FeatureLoadingStrategy = gws.FeatureLoadingStrategy.all
111 """Feature loading strategy."""
112 metadata: Optional[gws.base.metadata.Config]
113 """Layer metadata."""
114 models: Optional[list[gws.ext.config.model]]
115 """Data models."""
116 opacity: float = 1
117 """Layer opacity."""
118 ows: Optional[ows.Config]
119 """Configuration for OWS services."""
120 templates: Optional[list[gws.ext.config.template]]
121 """Layer templates."""
122 title: str = ''
123 """Layer title."""
124 zoom: Optional[gws.gis.zoom.Config]
125 """Layer resolutions and scales."""
126 withSearch: Optional[bool] = True
127 """Layer is searchable."""
128 withLegend: Optional[bool] = True
129 """Layer has a legend."""
130 withCache: Optional[bool] = False
131 """Layer is cached."""
132 withOws: Optional[bool] = True
133 """Layer is enabled for OWS services."""
136class Props(gws.Props):
137 clientOptions: gws.LayerClientOptions
138 cssSelector: str
139 displayMode: str
140 extent: Optional[gws.Extent]
141 zoomExtent: Optional[gws.Extent]
142 geometryType: Optional[gws.GeometryType]
143 grid: GridProps
144 layers: Optional[list['Props']]
145 loadingStrategy: gws.FeatureLoadingStrategy
146 metadata: gws.base.metadata.Props
147 model: Optional[gws.base.model.Props]
148 opacity: Optional[float]
149 resolutions: Optional[list[float]]
150 title: str = ''
151 type: str
152 uid: str
153 url: str = ''
156_DEFAULT_IMAGE_FORMAT = gws.lib.image.FormatConfig(mimeTypes=['image/png'], options={'mode': 'P'})
159class Object(gws.Layer):
160 parent: gws.Layer
162 clientOptions: gws.LayerClientOptions
163 cssSelector: str
165 canRenderBox = False
166 canRenderSvg = False
167 canRenderXyz = False
169 isEnabledForOws = False
170 isGroup = False
171 isSearchable = False
173 hasLegend = False
175 parentBounds: gws.Bounds
176 parentResolutions: list[float]
178 def configure(self):
179 self.clientOptions = self.cfg('clientOptions') or gws.Data()
180 self.cssSelector = self.cfg('cssSelector')
181 self.displayMode = self.cfg('display')
182 self.loadingStrategy = self.cfg('loadingStrategy')
183 self.opacity = self.cfg('opacity')
184 self.title = self.cfg('title')
186 p = self.cfg('imageFormat') or _DEFAULT_IMAGE_FORMAT
187 self.imageFormat = gws.ImageFormat(mimeTypes=p.mimeTypes, options=p.options or {})
189 self.parentBounds = self.cfg('_parentBounds')
190 self.parentResolutions = self.cfg('_parentResolutions')
191 self.mapCrs = self.parentBounds.crs
193 self.bounds = self.parentBounds
194 self.zoomBounds = cast(gws.Bounds, None)
195 self.resolutions = self.parentResolutions
197 self.templates = []
198 self.models = []
199 self.finders = []
201 self.metadata = gws.base.metadata.new()
202 self.legend = None
203 self.legendUrl = ''
205 self.layers = []
207 self.grid = None
208 self.cache = None
209 self.ows = gws.LayerOws()
211 setattr(self, 'provider', None)
212 self.sourceLayers = []
214 def configure_layer(self):
215 """Layer configuration protocol."""
216 self.configure_provider()
217 self.configure_sources()
218 self.configure_models()
219 self.configure_bounds()
220 self.configure_zoom_bounds()
221 self.configure_resolutions()
222 self.configure_grid()
223 self.configure_legend()
224 self.configure_cache()
225 self.configure_metadata()
226 self.configure_templates()
227 self.configure_search()
228 self.configure_ows()
230 ##
232 def configure_bounds(self):
233 p = self.cfg('extent')
234 if p:
235 ext = gws.lib.extent.from_list(p)
236 if not ext or not gws.lib.extent.is_valid(ext):
237 raise gws.ConfigurationError(f'invalid extent {p!r}')
238 self.bounds = gws.Bounds(crs=self.mapCrs, extent=ext)
239 return True
241 def configure_zoom_bounds(self):
242 p = self.cfg('zoomExtent')
243 if p:
244 ext = gws.lib.extent.from_list(p)
245 if not ext or not gws.lib.extent.is_valid(ext):
246 raise gws.ConfigurationError(f'invalid extent {p!r}')
247 self.zoomBounds = gws.Bounds(crs=self.mapCrs, extent=ext)
248 return True
250 def configure_cache(self):
251 if not self.cfg('withCache'):
252 return True
253 self.cache = gws.LayerCache(self.cfg('cache'))
254 return True
256 def configure_grid(self):
257 p = self.cfg('grid')
258 if p:
259 if p.crs and p.crs != self.bounds.crs:
260 raise gws.Error(f'layer {self!r}: invalid target grid crs')
261 self.grid = gws.TileGrid(
262 origin=p.origin or gws.Origin.nw,
263 tileSize=p.tileSize or DEFAULT_TILE_SIZE,
264 bounds=gws.Bounds(crs=self.bounds.crs, extent=p.extent),
265 resolutions=p.resolutions,
266 )
267 return True
269 def configure_legend(self):
270 if not self.cfg('withLegend'):
271 return True
272 p = self.cfg('legend')
273 if p:
274 self.legend = self.create_child(gws.ext.object.legend, p)
275 return True
277 def configure_metadata(self):
278 p = self.cfg('metadata')
279 if p:
280 self.metadata = gws.base.metadata.from_config(p)
281 return True
283 def configure_models(self):
284 return gws.config.util.configure_models_for(self)
286 def configure_provider(self):
287 pass
289 def configure_resolutions(self):
290 p = self.cfg('zoom')
291 if p:
292 self.resolutions = gws.gis.zoom.resolutions_from_config(p, self.cfg('_parentResolutions'))
293 if not self.resolutions:
294 raise gws.Error(f'layer {self!r}: no resolutions, config={p!r} parent={self.parentResolutions!r}')
295 return True
297 def configure_search(self):
298 if not self.cfg('withSearch'):
299 return True
300 return gws.config.util.configure_finders_for(self)
302 def configure_sources(self):
303 pass
305 def configure_templates(self):
306 return gws.config.util.configure_templates_for(self)
308 def configure_group_layers(self, layer_configs):
309 ls = []
311 for cfg in layer_configs:
312 cfg = gws.u.merge(
313 cfg,
314 _parentBounds=self.bounds,
315 _parentResolutions=self.resolutions,
316 )
317 ls.append(self.create_child(gws.ext.object.layer, cfg))
319 self.layers = gws.u.compact(ls)
321 def configure_ows(self):
322 self.isEnabledForOws = self.cfg('withOws', default=True)
323 self.ows = self.create_child(ows.Object, self.cfg('ows'), _defaultName=gws.u.to_uid(self.title))
325 ##
327 def post_configure(self):
328 self.isSearchable = bool(self.finders)
329 self.hasLegend = bool(self.legend)
331 if self.bounds.crs != self.mapCrs:
332 raise gws.Error(f'layer {self!r}: invalid CRS {self.bounds.crs}')
334 if not gws.lib.bounds.intersect(self.bounds, self.parentBounds):
335 gws.log.warning(f'layer {self!r}: bounds outside of the parent bounds b={self.bounds.extent} parent={self.parentBounds.extent}')
336 self.bounds = gws.lib.bounds.copy(self.parentBounds)
338 self.wgsExtent = gws.lib.bounds.transform(self.bounds, gws.lib.crs.WGS84).extent
339 self.zoomBounds = self.zoomBounds or self.bounds
341 if self.legend:
342 self.legendUrl = self.url_path('legend')
344 ##
346 # @TODO use Node.find_ancestors
348 def ancestors(self):
349 ls = []
350 p = self.parent
351 while isinstance(p, Object):
352 ls.append(p)
353 p = p.parent
354 return ls
356 def descendants(self):
357 ls = []
358 for la in self.layers:
359 ls.append(la)
360 ls.extend(la.descendants())
361 return ls
363 def url_path(self, kind):
364 ext = gws.lib.mime.extension_for(self.imageFormat.mimeTypes[0])
365 url_path_suffix = '/gws.' + ext
367 # layer urls, handled by the map action (base/map/action.py)
368 if kind == 'box':
369 return gws.u.action_url_path('mapGetBox', layerUid=self.uid) + url_path_suffix
370 if kind == 'tile':
371 return gws.u.action_url_path('mapGetXYZ', layerUid=self.uid) + '/z/{z}/x/{x}/y/{y}' + url_path_suffix
372 if kind == 'legend':
373 return gws.u.action_url_path('mapGetLegend', layerUid=self.uid) + url_path_suffix
374 if kind == 'features':
375 return gws.u.action_url_path('mapGetFeatures', layerUid=self.uid)
377 def props(self, user):
378 p = Props(
379 clientOptions=self.clientOptions,
380 cssSelector=self.cssSelector,
381 displayMode=self.displayMode,
382 extent=self.bounds.extent,
383 zoomExtent=self.zoomBounds.extent,
384 layers=self.layers,
385 loadingStrategy=self.loadingStrategy,
386 metadata=gws.base.metadata.props(self.metadata),
387 opacity=self.opacity,
388 resolutions=sorted(self.resolutions, reverse=True),
389 title=self.title,
390 uid=self.uid,
391 )
393 if self.grid:
394 p.grid = GridProps(
395 origin=self.grid.origin,
396 extent=self.grid.bounds.extent,
397 resolutions=sorted(self.grid.resolutions, reverse=True),
398 tileSize=self.grid.tileSize,
399 )
401 if self.displayMode == gws.LayerDisplayMode.tile:
402 p.type = 'tile'
403 p.url = self.url_path('tile')
405 if self.displayMode == gws.LayerDisplayMode.box:
406 p.type = 'box'
407 p.url = self.url_path('box')
409 return p
411 def render(self, lri):
412 pass
414 def find_features(self, search, user):
415 return []
417 def render_legend(self, args=None) -> Optional[gws.LegendRenderOutput]:
418 if not self.legend:
419 return None
421 def _get():
422 out = self.legend.render()
423 return out
425 if not args:
426 return gws.u.get_server_global('legend_' + self.uid, _get)
428 return self.legend.render(args)
430 def mapproxy_config(self, mc):
431 pass