Coverage for gws-app/gws/base/layer/core.py: 75%
254 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""Base layer object and the configuration shared by all layers."""
3from typing import Optional, cast
5import gws
6import gws.base.model
7import gws.config.util
8import gws.lib.grid
9import gws.lib.extent
10import gws.gis.source
11import gws.gis.zoom
12import gws.base.metadata
13import gws.base.legend
14import gws.lib.mime
15import gws.lib.image
16import gws.gis.cache
18from . import ows
20DEFAULT_IMAGE_FORMAT = gws.lib.image.format.PNG8
23class AutoLayersConfig(gws.ConfigWithAccess):
24 """Configuration merged into automatically created layers that match a filter."""
26 applyTo: Optional[gws.gis.source.LayerFilter]
27 """Filter for the source layers the configuration applies to."""
28 config: dict
29 """Layer configuration to merge into each matching layer."""
32class ClientConfig(gws.Data):
33 """Display options for the layer in the client layer tree."""
35 expanded: bool = False
36 """Show the layer's children expanded in the layer tree."""
37 unlisted: bool = False
38 """Hide the layer from the layer tree."""
39 selected: bool = False
40 """Select the layer in the layer tree initially."""
41 hidden: bool = False
42 """Hide the layer on the map initially."""
43 unfolded: bool = False
44 """Show the layer's children in the tree instead of the layer itself."""
45 exclusive: bool = False
46 """Allow only one child layer to be visible at a time."""
47 treeClassName = ''
48 """CSS class for the layer's item in the layer tree."""
51class ClientOptions(gws.Data):
52 """Client options for a layer."""
54 exclusive: bool
55 """Only one of this layer children is visible at a time."""
56 expanded: bool
57 """A layer is expanded in the list view."""
58 hidden: bool
59 """A layer is initially hidden."""
60 selected: bool
61 """A layer is initially selected."""
62 treeClassName: str
63 """CSS class name for the layer tree item."""
64 unfolded: bool
65 """A layer is not listed, but its children are."""
66 unlisted: bool
67 """A layer is hidden in the list view."""
70class Config(gws.ConfigWithAccess):
71 """Layer configuration"""
73 cache: Optional[gws.gis.cache.LayerConfig]
74 """Tile cache options."""
75 clientOptions: Optional[ClientConfig]
76 """Display options in the client layer tree."""
77 cssSelector: str = ''
78 """CSS selector for styling the layer's features in the client."""
79 display: gws.LayerDisplayMode = gws.LayerDisplayMode.box
80 """The way the layer is rendered in the client."""
81 extent: Optional[gws.Extent]
82 """Layer extent in map CRS coordinates, clipped to the parent extent."""
83 zoomExtent: Optional[gws.Extent]
84 """Extent to zoom to when zooming to the layer."""
85 extentBuffer: Optional[int]
86 """Ignored. (deprecated in 8.5)"""
87 finders: Optional[list[gws.ext.config.finder]]
88 """Search providers for the layer's features."""
89 grid: Optional[dict]
90 """Ignored. (deprecated in 8.5)"""
91 imageFormat: Optional[gws.lib.image.format.Config]
92 """Format of rendered and cached images."""
93 legend: Optional[gws.ext.config.legend]
94 """Custom legend or legend options for the layer."""
95 loadingStrategy: gws.FeatureLoadingStrategy = gws.FeatureLoadingStrategy.all
96 """When the client loads the layer's features."""
97 metadata: Optional[gws.base.metadata.Config]
98 """Layer metadata."""
99 models: Optional[list[gws.ext.config.model]]
100 """Data models for the layer's features."""
101 opacity: float = 1
102 """Layer opacity, from 0 to 1."""
103 ows: Optional[ows.Config]
104 """Layer names, namespace and allowed services for OWS services."""
105 templates: Optional[list[gws.ext.config.template]]
106 """Templates for the layer's description and feature info."""
107 title: str = ''
108 """Layer title, shown in the layer tree."""
109 zoom: Optional[gws.gis.zoom.Config]
110 """Scales or zoom levels at which the layer is visible."""
111 withSearch: Optional[bool] = True
112 """Include the layer in searches."""
113 withLegend: Optional[bool] = True
114 """Show a legend for the layer."""
115 withCache: Optional[bool] = True
116 """Store rendered tiles in the persistent cache. (changed in 8.5)"""
117 withOws: Optional[bool] = True
118 """Publish the layer in OWS services."""
121class Props(gws.Props):
122 clientOptions: ClientOptions
123 cssSelector: str
124 displayMode: str
125 extent: Optional[gws.Extent]
126 zoomExtent: Optional[gws.Extent]
127 geometryType: Optional[gws.GeometryType]
128 grid: gws.lib.grid.Props
129 layers: Optional[list['Props']]
130 loadingStrategy: gws.FeatureLoadingStrategy
131 metadata: gws.base.metadata.Props
132 model: Optional[gws.base.model.Props]
133 opacity: Optional[float]
134 resolutions: Optional[list[float]]
135 title: str = ''
136 type: str
137 uid: str
138 url: str = ''
141class Object(gws.Layer):
142 """Base layer.
144 Provides the attributes common to all layers, the configuration protocol
145 ``configure_layer``, client props and the URLs of the map action commands.
146 Subclasses call ``configure_layer`` from their ``configure`` method,
147 override the ``configure_*`` steps they need and implement ``render_box``,
148 ``render_tile`` or ``render_svg`` for the outputs they support.
149 """
151 parent: gws.Layer
152 """Parent layer."""
154 clientOptions: ClientOptions
155 """Display options for the client layer tree."""
157 canRenderBox = False
158 canRenderSvg = False
159 canRenderTile = False
160 canRenderInClient = False
162 isEnabledForOws = False
163 isGroup = False
164 isSearchable = False
166 hasLegend = False
168 parentWgsExtent: gws.Extent
169 """WGS84 extent of the parent layer or map."""
170 parentResolutions: list[float]
171 """Resolutions of the parent layer or map."""
173 def configure(self):
174 if self.cfg('grid') is not None:
175 self.root.config_warning('"layer.grid" is deprecated and ignored')
176 if self.cfg('extentBuffer') is not None:
177 self.root.config_warning('"layer.extentBuffer" is deprecated and ignored')
179 self.clientOptions = self.cfg('clientOptions') or gws.Data()
180 self.cssSelector = self.cfg('cssSelector')
182 self.displayMode = self.cfg('display')
183 if self.displayMode == gws.LayerDisplayMode.client and not self.canRenderInClient:
184 raise gws.ConfigurationError(f'display mode "client" is not supported for this layer')
186 self.loadingStrategy = self.cfg('loadingStrategy')
187 self.opacity = self.cfg('opacity')
188 self.title = self.cfg('title')
190 self.imageFormat = DEFAULT_IMAGE_FORMAT
191 p = self.cfg('imageFormat')
192 if p:
193 self.imageFormat = gws.lib.image.format.from_config(p)
195 self.parentWgsExtent = self.cfg('_parentWgsExtent')
196 self.parentResolutions = self.cfg('_parentResolutions')
197 self.mapCrs = self.cfg('_mapCrs')
199 self.wgsExtent = self.parentWgsExtent
200 self.bounds = cast(gws.Bounds, None)
201 self.zoomBounds = cast(gws.Bounds, None)
202 self.resolutions = self.parentResolutions
204 self.templates = []
205 self.models = []
206 self.finders = []
208 self.metadata = gws.base.metadata.new()
209 self.legend = None
211 self.layers = []
212 self.sourceLayers = []
214 self.grabbers = {}
216 def configure_layer(self):
217 """Run the layer configuration steps in a fixed order.
219 The steps are provider, sources, group, models, extent, bounds, zoom
220 bounds, resolutions, legend, metadata, templates, search and OWS.
221 """
223 self.configure_provider()
224 self.configure_sources()
225 self.configure_group()
226 self.configure_models()
227 self.configure_extent()
228 self.configure_bounds()
229 self.configure_zoom_bounds()
230 self.configure_resolutions()
231 self.configure_legend()
232 self.configure_metadata()
233 self.configure_templates()
234 self.configure_search()
235 self.configure_ows()
237 ##
239 def configure_group(self):
240 """Create child layers. Does nothing in the base class.
242 Returns:
243 ``True`` if child layers were created, ``None`` otherwise.
244 """
245 pass
247 def configure_extent(self):
248 """Set ``wgsExtent`` from the configured ``extent``.
250 Returns:
251 ``True`` if an extent is configured, ``None`` otherwise.
253 Raises:
254 ``gws.ConfigurationError``: If the configured extent is invalid.
255 """
256 p = self.cfg('extent')
257 if p:
258 ext = gws.lib.extent.from_list(p)
259 if not ext or not gws.lib.extent.is_valid(ext):
260 raise gws.ConfigurationError(f'invalid extent {p!r}')
261 self.wgsExtent = gws.lib.extent.transform_to_wgs(ext, self.mapCrs)
262 return True
264 def configure_bounds(self):
265 """Set ``bounds`` in the map CRS.
267 The bounds are the WGS extent clipped to the parent extent and to the
268 CRS. If the extent lies outside of the parent extent, a configuration
269 warning is issued and the parent extent is used.
271 Returns:
272 ``True``.
274 Raises:
275 ``gws.ConfigurationError``: If no extent can be determined.
276 """
278 extent = gws.lib.extent.intersection(self.wgsExtent, self.parentWgsExtent)
279 if extent:
280 extent = self.mapCrs.clip_wgs_extent(extent)
281 if extent is None:
282 self.root.config_warning(f'layer extent outside of the parent extent wgs={self.wgsExtent}')
283 extent = self.mapCrs.clip_wgs_extent(self.parentWgsExtent)
284 if extent is None:
285 raise gws.ConfigurationError(f'layer extent could not be determined wgs={self.wgsExtent}')
286 self.bounds = gws.Bounds(
287 crs=self.mapCrs,
288 extent=gws.lib.extent.transform_from_wgs(extent, self.mapCrs),
289 )
290 return True
292 def configure_zoom_bounds(self):
293 """Set ``zoomBounds`` from the configured ``zoomExtent``.
295 Returns:
296 ``True`` if a zoom extent is configured, ``None`` otherwise.
298 Raises:
299 ``gws.ConfigurationError``: If the configured zoom extent is invalid.
300 """
301 p = self.cfg('zoomExtent')
302 if p:
303 ext = gws.lib.extent.from_list(p)
304 if not ext or not gws.lib.extent.is_valid(ext):
305 raise gws.ConfigurationError(f'invalid extent {p!r}')
306 self.zoomBounds = gws.Bounds(crs=self.mapCrs, extent=ext)
307 return True
309 def configure_legend(self):
310 """Create the legend from the ``legend`` configuration.
312 Returns:
313 ``True`` if a legend is configured or legends are disabled with
314 ``withLegend``, ``None`` otherwise.
315 """
316 if not self.cfg('withLegend'):
317 return True
318 p = self.cfg('legend')
319 if p:
320 self.legend = self.create_child(gws.ext.object.legend, p)
321 return True
323 def configure_metadata(self):
324 """Set ``metadata`` from the ``metadata`` configuration.
326 Returns:
327 ``True`` if metadata is configured, ``None`` otherwise.
328 """
329 p = self.cfg('metadata')
330 if p:
331 self.metadata = gws.base.metadata.from_config(p)
332 return True
334 def configure_models(self):
335 """Create the models from the ``models`` configuration.
337 Returns:
338 ``True`` if models are configured, ``False`` otherwise.
339 """
340 return gws.config.util.configure_models_for(self)
342 def configure_provider(self):
343 """Set up the data provider. Does nothing in the base class.
345 Returns:
346 ``True`` if a provider was configured, ``None`` otherwise.
347 """
348 pass
350 def configure_resolutions(self):
351 """Set ``resolutions`` from the ``zoom`` configuration.
353 Without a ``zoom`` configuration the layer keeps the parent resolutions.
355 Returns:
356 ``True`` if a zoom configuration is present, ``None`` otherwise.
358 Raises:
359 ``gws.Error``: If the zoom configuration yields no resolutions.
360 """
361 p = self.cfg('zoom')
362 if p:
363 gws.gis.zoom.warn_deprecated_options(p, self.root)
364 self.resolutions = gws.gis.zoom.resolutions_for_layer(p, self.cfg('_parentResolutions'), self.mapCrs)
365 if not self.resolutions:
366 raise gws.Error(f'layer {self!r}: no resolutions, config={p!r} parent={self.parentResolutions!r}')
367 return True
369 def configure_search(self):
370 """Create the search providers from the ``finders`` configuration.
372 Returns:
373 ``True`` if finders are configured or search is disabled with
374 ``withSearch``, ``False`` otherwise.
375 """
376 if not self.cfg('withSearch'):
377 return True
378 return gws.config.util.configure_finders_for(self)
380 def configure_sources(self):
381 """Set up the source layers. Does nothing in the base class.
383 Returns:
384 ``True`` if sources were configured, ``None`` otherwise.
385 """
386 pass
388 def configure_templates(self):
389 """Create the templates from the ``templates`` configuration.
391 Returns:
392 ``True`` if at least one template was created, ``False`` otherwise.
393 """
394 return gws.config.util.configure_templates_for(self)
396 def configure_ows(self):
397 """Create the OWS binding and set ``isEnabledForOws`` from ``withOws``.
399 The OWS layer and feature names default to the layer title converted
400 to a UID.
401 """
402 self.isEnabledForOws = self.cfg('withOws', default=True)
403 self.ows = self.create_child(ows.Object, self.cfg('ows'), _defaultName=gws.u.to_uid(self.title))
405 ##
407 def post_configure(self):
408 self.isSearchable = bool(self.finders)
409 self.hasLegend = bool(self.legend)
411 self.zoomBounds = self.zoomBounds or self.bounds
413 ##
415 def url_path_for(self, kind):
416 ext = gws.lib.mime.extension_for(self.imageFormat.mimeTypes[0])
417 url_path_suffix = '/gws.' + ext
419 # layer urls, handled by the map action (base/map/action.py)
420 if kind == 'box':
421 return gws.u.action_url_path('mapGetBox', layerUid=self.uid) + url_path_suffix
422 if kind == 'tile':
423 return gws.u.action_url_path('mapGetTile', layerUid=self.uid) + '/z/{z}/x/{x}/y/{y}' + url_path_suffix
424 if kind == 'legend':
425 legend_ext = gws.lib.mime.extension_for(gws.base.legend.IMAGE_FORMAT.mimeTypes[0])
426 return gws.u.action_url_path('mapGetLegend', layerUid=self.uid) + '/gws.' + legend_ext
427 if kind == 'features':
428 return gws.u.action_url_path('mapGetFeatures', layerUid=self.uid)
429 raise gws.Error(f'invalid argument {kind=}')
431 def props(self, user):
432 p = Props(
433 clientOptions=self.clientOptions,
434 cssSelector=self.cssSelector,
435 displayMode=self.displayMode,
436 extent=self.bounds.extent,
437 zoomExtent=self.zoomBounds.extent,
438 layers=self.layers,
439 loadingStrategy=self.loadingStrategy,
440 metadata=gws.base.metadata.props(self.metadata),
441 opacity=self.opacity,
442 resolutions=sorted(self.resolutions, reverse=True),
443 title=self.title,
444 uid=self.uid,
445 )
447 gr = self.grabbers.get(self.mapCrs.srid)
448 if gr:
449 p.grid = gws.lib.grid.props_for_resolutions(gr.grid, self.resolutions)
451 if self.displayMode == gws.LayerDisplayMode.tile:
452 p.type = 'tile'
453 p.url = self.url_path_for('tile')
455 if self.displayMode == gws.LayerDisplayMode.box:
456 p.type = 'box'
457 p.url = self.url_path_for('box')
459 return p
461 def grabber_for(self, lri: gws.LayerRenderInput) -> Optional[gws.Grabber]:
462 """Find the grabber for the target CRS of a render request.
464 Args:
465 lri: Render input; without ``targetCrs`` the map CRS is used.
467 Returns:
468 The grabber, or ``None`` if there is none for the CRS.
469 """
470 crs = lri.targetCrs or self.mapCrs
471 return self.grabbers.get(crs.srid)
473 def find_features(self, search, user):
474 return []
476 def render(self, lri):
477 if lri.type == gws.LayerRenderInputType.box:
478 return self.render_box(lri)
479 if lri.type == gws.LayerRenderInputType.tile:
480 return self.render_tile(lri)
481 if lri.type == gws.LayerRenderInputType.svg:
482 return self.render_svg(lri)
484 def render_box(self, lri):
485 """Render the layer as a single image. Returns nothing in the base class.
487 Args:
488 lri: Render input with the map view.
490 Returns:
491 Render output with the encoded image, or ``None``.
492 """
493 pass
495 def render_tile(self, lri):
496 """Render a single tile. Returns nothing in the base class.
498 Args:
499 lri: Render input with the tile coordinates.
501 Returns:
502 Render output with the encoded tile, or ``None``.
503 """
504 pass
506 def render_svg(self, lri):
507 """Render the layer as SVG. Returns nothing in the base class.
509 Args:
510 lri: Render input with the map view and style.
512 Returns:
513 Render output with SVG tags, or ``None``.
514 """
515 pass
517 def render_legend(self, args=None):
518 if not self.legend:
519 return
521 legend = self.legend
522 if not args:
523 return gws.u.get_server_global('legend_' + self.uid, legend.render)
524 return legend.render(args)