Coverage for gws-app/gws/base/layer/core.py: 75%
247 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"""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.lib.mime
14import gws.lib.image
15import gws.gis.cache
17from . import ows
19DEFAULT_IMAGE_FORMAT = gws.ImageFormat(name='png8', mimeTypes=['image/png'], options={'mode': 'P'})
22class AutoLayersConfig(gws.ConfigWithAccess):
23 """Configuration merged into automatically created layers that match a filter."""
25 applyTo: Optional[gws.gis.source.LayerFilter]
26 """Filter for the source layers the configuration applies to."""
27 config: dict
28 """Layer configuration to merge into each matching layer."""
31class ClientConfig(gws.Data):
32 """Display options for the layer in the client layer tree."""
34 expanded: bool = False
35 """Show the layer's children expanded in the layer tree."""
36 unlisted: bool = False
37 """Hide the layer from the layer tree."""
38 selected: bool = False
39 """Select the layer in the layer tree initially."""
40 hidden: bool = False
41 """Hide the layer on the map initially."""
42 unfolded: bool = False
43 """Show the layer's children in the tree instead of the layer itself."""
44 exclusive: bool = False
45 """Allow only one child layer to be visible at a time."""
46 treeClassName = ''
47 """CSS class for the layer's item in the layer tree."""
50class ClientOptions(gws.Data):
51 """Client options for a layer."""
53 exclusive: bool
54 """Only one of this layer children is visible at a time."""
55 expanded: bool
56 """A layer is expanded in the list view."""
57 hidden: bool
58 """A layer is initially hidden."""
59 selected: bool
60 """A layer is initially selected."""
61 treeClassName: str
62 """CSS class name for the layer tree item."""
63 unfolded: bool
64 """A layer is not listed, but its children are."""
65 unlisted: bool
66 """A layer is hidden in the list view."""
69class Config(gws.ConfigWithAccess):
70 """Layer configuration"""
72 cache: Optional[gws.gis.cache.LayerConfig]
73 """Tile cache options."""
74 clientOptions: Optional[ClientConfig]
75 """Display options in the client layer tree."""
76 cssSelector: str = ''
77 """CSS selector for styling the layer's features in the client."""
78 display: gws.LayerDisplayMode = gws.LayerDisplayMode.box
79 """The way the layer is rendered in the client."""
80 extent: Optional[gws.Extent]
81 """Layer extent in map CRS coordinates, clipped to the parent extent."""
82 zoomExtent: Optional[gws.Extent]
83 """Extent to zoom to when zooming to the layer."""
84 extentBuffer: Optional[int]
85 """Buffer around the layer extent."""
86 finders: Optional[list[gws.ext.config.finder]]
87 """Search providers for the layer's features."""
88 grid: Optional[dict]
89 """Ignored. (deprecated in 8.5)"""
90 imageFormat: Optional[gws.lib.image.FormatConfig]
91 """Format of rendered and cached images."""
92 legend: Optional[gws.ext.config.legend]
93 """Custom legend or legend options for the layer."""
94 loadingStrategy: gws.FeatureLoadingStrategy = gws.FeatureLoadingStrategy.all
95 """When the client loads the layer's features."""
96 metadata: Optional[gws.base.metadata.Config]
97 """Layer metadata."""
98 models: Optional[list[gws.ext.config.model]]
99 """Data models for the layer's features."""
100 opacity: float = 1
101 """Layer opacity, from 0 to 1."""
102 ows: Optional[ows.Config]
103 """Layer names, namespace and allowed services for OWS services."""
104 templates: Optional[list[gws.ext.config.template]]
105 """Templates for the layer's description and feature info."""
106 title: str = ''
107 """Layer title, shown in the layer tree."""
108 zoom: Optional[gws.gis.zoom.Config]
109 """Scales or zoom levels at which the layer is visible."""
110 withSearch: Optional[bool] = True
111 """Include the layer in searches."""
112 withLegend: Optional[bool] = True
113 """Show a legend for the layer."""
114 withCache: Optional[bool] = True
115 """Store rendered tiles in the persistent cache. (changed in 8.5)"""
116 withOws: Optional[bool] = True
117 """Publish the layer in OWS services."""
120class Props(gws.Props):
121 clientOptions: ClientOptions
122 cssSelector: str
123 displayMode: str
124 extent: Optional[gws.Extent]
125 zoomExtent: Optional[gws.Extent]
126 geometryType: Optional[gws.GeometryType]
127 grid: gws.lib.grid.Props
128 layers: Optional[list['Props']]
129 loadingStrategy: gws.FeatureLoadingStrategy
130 metadata: gws.base.metadata.Props
131 model: Optional[gws.base.model.Props]
132 opacity: Optional[float]
133 resolutions: Optional[list[float]]
134 title: str = ''
135 type: str
136 uid: str
137 url: str = ''
140class Object(gws.Layer):
141 """Base layer.
143 Provides the attributes common to all layers, the configuration protocol
144 ``configure_layer``, client props and the URLs of the map action commands.
145 Subclasses call ``configure_layer`` from their ``configure`` method,
146 override the ``configure_*`` steps they need and implement ``render_box``,
147 ``render_tile`` or ``render_svg`` for the outputs they support.
148 """
150 parent: gws.Layer
151 """Parent layer."""
153 clientOptions: ClientOptions
154 """Display options for the client layer tree."""
156 canRenderBox = False
157 canRenderSvg = False
158 canRenderTile = False
159 canRenderInClient = False
161 isEnabledForOws = False
162 isGroup = False
163 isSearchable = False
165 hasLegend = False
167 parentWgsExtent: gws.Extent
168 """WGS84 extent of the parent layer or map."""
169 parentResolutions: list[float]
170 """Resolutions of the parent layer or map."""
172 def configure(self):
173 if self.cfg('grid') is not None:
174 self.root.config_warning('"layer.grid" is deprecated and ignored')
176 self.clientOptions = self.cfg('clientOptions') or gws.Data()
177 self.cssSelector = self.cfg('cssSelector')
179 self.displayMode = self.cfg('display')
180 if self.displayMode == gws.LayerDisplayMode.client and not self.canRenderInClient:
181 raise gws.ConfigurationError(f'display mode "client" is not supported for this layer')
183 self.loadingStrategy = self.cfg('loadingStrategy')
184 self.opacity = self.cfg('opacity')
185 self.title = self.cfg('title')
187 self.imageFormat = DEFAULT_IMAGE_FORMAT
188 p = self.cfg('imageFormat')
189 if p:
190 self.imageFormat = gws.ImageFormat(name=p.name, mimeTypes=p.mimeTypes, options=p.options or {})
192 self.parentWgsExtent = self.cfg('_parentWgsExtent')
193 self.parentResolutions = self.cfg('_parentResolutions')
194 self.mapCrs = self.cfg('_mapCrs')
196 self.wgsExtent = self.parentWgsExtent
197 self.bounds = cast(gws.Bounds, None)
198 self.zoomBounds = cast(gws.Bounds, None)
199 self.resolutions = self.parentResolutions
201 self.templates = []
202 self.models = []
203 self.finders = []
205 self.metadata = gws.base.metadata.new()
206 self.legend = None
208 self.layers = []
209 self.sourceLayers = []
211 self.grabbers = {}
213 def configure_layer(self):
214 """Run the layer configuration steps in a fixed order.
216 The steps are provider, sources, group, models, extent, bounds, zoom
217 bounds, resolutions, legend, metadata, templates, search and OWS.
218 """
220 self.configure_provider()
221 self.configure_sources()
222 self.configure_group()
223 self.configure_models()
224 self.configure_extent()
225 self.configure_bounds()
226 self.configure_zoom_bounds()
227 self.configure_resolutions()
228 self.configure_legend()
229 self.configure_metadata()
230 self.configure_templates()
231 self.configure_search()
232 self.configure_ows()
234 ##
236 def configure_group(self):
237 """Create child layers. Does nothing in the base class.
239 Returns:
240 ``True`` if child layers were created, ``None`` otherwise.
241 """
242 pass
244 def configure_extent(self):
245 """Set ``wgsExtent`` from the configured ``extent``.
247 Returns:
248 ``True`` if an extent is configured, ``None`` otherwise.
250 Raises:
251 ``gws.ConfigurationError``: If the configured extent is invalid.
252 """
253 p = self.cfg('extent')
254 if p:
255 ext = gws.lib.extent.from_list(p)
256 if not ext or not gws.lib.extent.is_valid(ext):
257 raise gws.ConfigurationError(f'invalid extent {p!r}')
258 self.wgsExtent = gws.lib.extent.transform_to_wgs(ext, self.mapCrs)
259 return True
261 def configure_bounds(self):
262 """Set ``bounds`` in the map CRS.
264 The bounds are the WGS extent clipped to the parent extent and to the
265 CRS. If the extent lies outside of the parent extent, a configuration
266 warning is issued and the parent extent is used.
268 Returns:
269 ``True``.
271 Raises:
272 ``gws.ConfigurationError``: If no extent can be determined.
273 """
275 extent = gws.lib.extent.intersection(self.wgsExtent, self.parentWgsExtent)
276 if extent:
277 extent = self.mapCrs.clip_wgs_extent(extent)
278 if extent is None:
279 self.root.config_warning(f'layer extent outside of the parent extent wgs={self.wgsExtent}')
280 extent = self.mapCrs.clip_wgs_extent(self.parentWgsExtent)
281 if extent is None:
282 raise gws.ConfigurationError(f'layer extent could not be determined wgs={self.wgsExtent}')
283 self.bounds = gws.Bounds(
284 crs=self.mapCrs,
285 extent=gws.lib.extent.transform_from_wgs(extent, self.mapCrs),
286 )
287 return True
289 def configure_zoom_bounds(self):
290 """Set ``zoomBounds`` from the configured ``zoomExtent``.
292 Returns:
293 ``True`` if a zoom extent is configured, ``None`` otherwise.
295 Raises:
296 ``gws.ConfigurationError``: If the configured zoom extent is invalid.
297 """
298 p = self.cfg('zoomExtent')
299 if p:
300 ext = gws.lib.extent.from_list(p)
301 if not ext or not gws.lib.extent.is_valid(ext):
302 raise gws.ConfigurationError(f'invalid extent {p!r}')
303 self.zoomBounds = gws.Bounds(crs=self.mapCrs, extent=ext)
304 return True
306 def configure_legend(self):
307 """Create the legend from the ``legend`` configuration.
309 Returns:
310 ``True`` if a legend is configured or legends are disabled with
311 ``withLegend``, ``None`` otherwise.
312 """
313 if not self.cfg('withLegend'):
314 return True
315 p = self.cfg('legend')
316 if p:
317 self.legend = self.create_child(gws.ext.object.legend, p)
318 return True
320 def configure_metadata(self):
321 """Set ``metadata`` from the ``metadata`` configuration.
323 Returns:
324 ``True`` if metadata is configured, ``None`` otherwise.
325 """
326 p = self.cfg('metadata')
327 if p:
328 self.metadata = gws.base.metadata.from_config(p)
329 return True
331 def configure_models(self):
332 """Create the models from the ``models`` configuration.
334 Returns:
335 ``True`` if models are configured, ``False`` otherwise.
336 """
337 return gws.config.util.configure_models_for(self)
339 def configure_provider(self):
340 """Set up the data provider. Does nothing in the base class.
342 Returns:
343 ``True`` if a provider was configured, ``None`` otherwise.
344 """
345 pass
347 def configure_resolutions(self):
348 """Set ``resolutions`` from the ``zoom`` configuration.
350 Without a ``zoom`` configuration the layer keeps the parent resolutions.
352 Returns:
353 ``True`` if a zoom configuration is present, ``None`` otherwise.
355 Raises:
356 ``gws.Error``: If the zoom configuration yields no resolutions.
357 """
358 p = self.cfg('zoom')
359 if p:
360 gws.gis.zoom.warn_deprecated_options(p, self.root)
361 self.resolutions = gws.gis.zoom.resolutions_for_layer(p, self.cfg('_parentResolutions'), self.mapCrs)
362 if not self.resolutions:
363 raise gws.Error(f'layer {self!r}: no resolutions, config={p!r} parent={self.parentResolutions!r}')
364 return True
366 def configure_search(self):
367 """Create the search providers from the ``finders`` configuration.
369 Returns:
370 ``True`` if finders are configured or search is disabled with
371 ``withSearch``, ``False`` otherwise.
372 """
373 if not self.cfg('withSearch'):
374 return True
375 return gws.config.util.configure_finders_for(self)
377 def configure_sources(self):
378 """Set up the source layers. Does nothing in the base class.
380 Returns:
381 ``True`` if sources were configured, ``None`` otherwise.
382 """
383 pass
385 def configure_templates(self):
386 """Create the templates from the ``templates`` configuration.
388 Returns:
389 ``True`` if at least one template was created, ``False`` otherwise.
390 """
391 return gws.config.util.configure_templates_for(self)
393 def configure_ows(self):
394 """Create the OWS binding and set ``isEnabledForOws`` from ``withOws``.
396 The OWS layer and feature names default to the layer title converted
397 to a UID.
398 """
399 self.isEnabledForOws = self.cfg('withOws', default=True)
400 self.ows = self.create_child(ows.Object, self.cfg('ows'), _defaultName=gws.u.to_uid(self.title))
402 ##
404 def post_configure(self):
405 self.isSearchable = bool(self.finders)
406 self.hasLegend = bool(self.legend)
408 self.zoomBounds = self.zoomBounds or self.bounds
410 ##
412 def url_path_for(self, kind):
413 ext = gws.lib.mime.extension_for(self.imageFormat.mimeTypes[0])
414 url_path_suffix = '/gws.' + ext
416 # layer urls, handled by the map action (base/map/action.py)
417 if kind == 'box':
418 return gws.u.action_url_path('mapGetBox', layerUid=self.uid) + url_path_suffix
419 if kind == 'tile':
420 return gws.u.action_url_path('mapGetTile', layerUid=self.uid) + '/z/{z}/x/{x}/y/{y}' + url_path_suffix
421 if kind == 'legend':
422 return gws.u.action_url_path('mapGetLegend', layerUid=self.uid) + url_path_suffix
423 if kind == 'features':
424 return gws.u.action_url_path('mapGetFeatures', layerUid=self.uid)
425 raise gws.Error(f'invalid argument {kind=}')
427 def props(self, user):
428 p = Props(
429 clientOptions=self.clientOptions,
430 cssSelector=self.cssSelector,
431 displayMode=self.displayMode,
432 extent=self.bounds.extent,
433 zoomExtent=self.zoomBounds.extent,
434 layers=self.layers,
435 loadingStrategy=self.loadingStrategy,
436 metadata=gws.base.metadata.props(self.metadata),
437 opacity=self.opacity,
438 resolutions=sorted(self.resolutions, reverse=True),
439 title=self.title,
440 uid=self.uid,
441 )
443 gr = self.grabbers.get(self.mapCrs.srid)
444 if gr:
445 p.grid = gws.lib.grid.props_for_resolutions(gr.grid, self.resolutions)
447 if self.displayMode == gws.LayerDisplayMode.tile:
448 p.type = 'tile'
449 p.url = self.url_path_for('tile')
451 if self.displayMode == gws.LayerDisplayMode.box:
452 p.type = 'box'
453 p.url = self.url_path_for('box')
455 return p
457 def find_features(self, search, user):
458 return []
460 def render(self, lri):
461 if lri.type == gws.LayerRenderInputType.box:
462 return self.render_box(lri)
463 if lri.type == gws.LayerRenderInputType.tile:
464 return self.render_tile(lri)
465 if lri.type == gws.LayerRenderInputType.svg:
466 return self.render_svg(lri)
468 def render_box(self, lri):
469 """Render the layer as a single image. Returns nothing in the base class.
471 Args:
472 lri: Render input with the map view.
474 Returns:
475 Render output with the encoded image, or ``None``.
476 """
477 pass
479 def render_tile(self, lri):
480 """Render a single tile. Returns nothing in the base class.
482 Args:
483 lri: Render input with the tile coordinates.
485 Returns:
486 Render output with the encoded tile, or ``None``.
487 """
488 pass
490 def render_svg(self, lri):
491 """Render the layer as SVG. Returns nothing in the base class.
493 Args:
494 lri: Render input with the map view and style.
496 Returns:
497 Render output with SVG tags, or ``None``.
498 """
499 pass
501 def render_legend(self, args=None):
502 if not self.legend:
503 return
505 legend = self.legend
506 if not args:
507 return gws.u.get_server_global('legend_' + self.uid, legend.render)
508 return legend.render(args)