Coverage for gws-app/gws/base/layer/tree.py: 43%
86 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"""Child layer configurations built from a hierarchy of source layers."""
3from typing import Optional, cast
4from collections.abc import Callable
6import gws
7import gws.gis.source
8import gws.config.parser
10from . import core
13class FlattenConfig(gws.Config):
14 """Turns source groups below a level into single layers."""
16 level: int
17 """Source hierarchy level from which groups become single layers."""
18 useGroups: bool = False
19 """Request a flattened group by its own name instead of its image layers."""
22class Config(gws.Config):
23 """Configuration for the layer tree."""
25 rootLayers: Optional[gws.gis.source.LayerFilter]
26 """Source layers to use as roots."""
27 excludeLayers: Optional[gws.gis.source.LayerFilter]
28 """Source layers to exclude."""
29 flattenLayers: Optional[FlattenConfig]
30 """Collapse source groups below a level into single layers."""
31 autoLayers: Optional[list[core.AutoLayersConfig]]
32 """Extra configuration for generated layers that match a filter."""
35class _TreeConfigArgs(gws.Data):
36 """Arguments for building a layer config tree."""
38 root: gws.Root
39 """Root object, provides the specs for parsing the generated configs."""
40 source_layers: list[gws.SourceLayer]
41 """Source layers to build the tree from."""
42 roots_slf: gws.gis.source.LayerFilter
43 """Filter for the source layers to use as roots."""
44 exclude_slf: gws.gis.source.LayerFilter
45 """Filter for the source layers to exclude."""
46 flatten_config: FlattenConfig
47 """Flattening options."""
48 auto_layers: list[core.AutoLayersConfig]
49 """Extra configurations merged into matching layers."""
50 create_leaf_layer_config: Callable
51 """Function that creates a leaf layer config from a list of source layers."""
54def configure_group_layers_for(layer: core.Object, source_layers: list[gws.SourceLayer], create_leaf_layer_config: Callable) -> bool:
55 """Create child layers of a layer from a list of source layers.
57 The layer's ``rootLayers``, ``excludeLayers``, ``flattenLayers`` and
58 ``autoLayers`` options control which source layers are used and how. Source
59 groups become ``group`` layers, other source layers are passed to
60 ``create_leaf_layer_config``.
62 Args:
63 layer: The layer to create children for.
64 source_layers: Source layer hierarchy.
65 create_leaf_layer_config: Function that takes a list of source layers
66 and returns a config for a leaf layer that shows them.
68 Returns:
69 ``True``.
71 Raises:
72 ``gws.ConfigurationError``: If a generated config is invalid.
73 ``gws.Error``: If no child layers could be created.
74 """
76 configs = _layer_configs_from_layer(layer, source_layers, create_leaf_layer_config)
77 create_group_layers(layer, configs)
78 return True
81def create_group_layers(layer: core.Object, layer_configs: list):
82 """Create child layers of a layer from their configs.
84 Each config gets the parent's WGS extent, resolutions and map CRS. The
85 created layers replace ``layer.layers``.
87 Args:
88 layer: The layer to create children for.
89 layer_configs: Child layer configs.
91 Raises:
92 ``gws.Error``: If no child layers could be created.
93 """
95 layer.layers = []
97 for cfg in layer_configs:
98 cfg = gws.u.merge(
99 cfg,
100 _parentWgsExtent=layer.wgsExtent,
101 _mapCrs=layer.mapCrs,
102 _parentResolutions=layer.resolutions,
103 )
104 la = cast(gws.Layer, layer.create_child(gws.ext.object.layer, cfg))
105 if la:
106 layer.layers.append(la)
108 if not layer.layers:
109 raise gws.Error(f'group is empty: {layer}')
112def _layer_configs_from_layer(layer: core.Object, source_layers: list[gws.SourceLayer], create_leaf_layer_config: Callable) -> list[gws.Config]:
113 """Generate a config tree from a list of source layers and the main layer config."""
115 return _layer_configs_from_args(
116 _TreeConfigArgs(
117 root=layer.root,
118 source_layers=source_layers,
119 roots_slf=layer.cfg('rootLayers'),
120 exclude_slf=layer.cfg('excludeLayers'),
121 flatten_config=layer.cfg('flattenLayers'),
122 auto_layers=layer.cfg('autoLayers', default=[]),
123 create_leaf_layer_config=create_leaf_layer_config,
124 )
125 )
128def _layer_configs_from_args(tca: _TreeConfigArgs) -> list[gws.Config]:
129 """Generate a config tree from a list of source layers."""
131 # by default, take top-level layers as roots
132 roots_slf = tca.roots_slf or gws.gis.source.LayerFilter(level=1)
133 roots = gws.gis.source.filter_layers(tca.source_layers, roots_slf)
135 # make configs...
136 configs = gws.u.compact(_config(tca, sl, 0) for sl in roots)
138 # configs need to be reparsed so that defaults can be injected
139 layer_configs = []
140 ctx = gws.ConfigContext(
141 specs=tca.root.specs,
142 readOptions={gws.SpecReadOption.acceptExtraProps, gws.SpecReadOption.allowMissing},
143 )
145 for c in configs:
146 cfg = gws.config.parser.parse_dict(
147 gws.u.to_dict(c),
148 path='',
149 as_type='gws.ext.config.layer',
150 ctx=ctx,
151 )
152 if cfg:
153 layer_configs.append(cfg)
155 if ctx.errors:
156 raise gws.ConfigurationError(ctx.errors[0].message)
158 return layer_configs
161def _config(tca: _TreeConfigArgs, sl: gws.SourceLayer, depth: int):
162 """Build the config for a source layer, with title, visibility, opacity and auto layer configs."""
163 cfg = _base_config(tca, sl, depth)
164 if not cfg:
165 return None
167 cfg = gws.u.merge(
168 gws.u.to_dict(cfg),
169 {
170 'title': sl.title,
171 'clientOptions': {
172 'hidden': not sl.isVisible,
173 'expanded': sl.isExpanded,
174 },
175 'opacity': sl.opacity or 1,
176 },
177 )
179 for cc in tca.auto_layers:
180 if gws.gis.source.layer_matches(sl, cc.applyTo):
181 cfg = _deep_merge(cfg, cc.config)
183 return gws.u.compact(cfg)
186def _base_config(tca: _TreeConfigArgs, sl: gws.SourceLayer, depth: int):
187 """Build a leaf, flattened or group config for a source layer, or None if it is excluded or empty."""
188 # source layer excluded by the filter
189 if tca.exclude_slf and gws.gis.source.layer_matches(sl, tca.exclude_slf):
190 return None
192 # leaf layer
193 if not sl.isGroup:
194 return tca.create_leaf_layer_config([sl])
196 # flattened group layer
197 # NB use the absolute level to compute flatness, could also use relative (=depth)
198 if tca.flatten_config and sl.aLevel >= tca.flatten_config.level:
199 if tca.flatten_config.useGroups:
200 return tca.create_leaf_layer_config([sl])
202 slf = gws.gis.source.LayerFilter(isImage=True)
203 leaves = gws.gis.source.filter_layers([sl], slf)
204 if not leaves:
205 return None
206 return tca.create_leaf_layer_config(leaves)
208 # ordinary group layer
209 layer_cfgs = gws.u.compact(_config(tca, sub, depth + 1) for sub in sl.layers)
210 if not layer_cfgs:
211 return None
212 return {
213 'type': 'group',
214 'layers': layer_cfgs,
215 }
218def _deep_merge(x, y):
219 """Merge two values recursively: dicts by key, lists by concatenation, otherwise y unless it is None."""
220 if (gws.u.is_dict(x) or gws.u.is_data_object(x)) and (gws.u.is_dict(y) or gws.u.is_data_object(y)):
221 xd = gws.u.to_dict(x)
222 yd = gws.u.to_dict(y)
223 d = {k: _deep_merge(xd.get(k), yd.get(k)) for k in xd.keys() | yd.keys()}
224 return d if gws.u.is_dict(x) else type(x)(d)
226 if gws.u.is_list(x) and gws.u.is_list(y):
227 return gws.u.compact(x) + gws.u.compact(y)
229 return y if y is not None else x