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

1"""Child layer configurations built from a hierarchy of source layers.""" 

2 

3from typing import Optional, cast 

4from collections.abc import Callable 

5 

6import gws 

7import gws.gis.source 

8import gws.config.parser 

9 

10from . import core 

11 

12 

13class FlattenConfig(gws.Config): 

14 """Turns source groups below a level into single layers.""" 

15 

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.""" 

20 

21 

22class Config(gws.Config): 

23 """Configuration for the layer tree.""" 

24 

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.""" 

33 

34 

35class _TreeConfigArgs(gws.Data): 

36 """Arguments for building a layer config tree.""" 

37 

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.""" 

52 

53 

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. 

56 

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``. 

61 

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. 

67 

68 Returns: 

69 ``True``. 

70 

71 Raises: 

72 ``gws.ConfigurationError``: If a generated config is invalid. 

73 ``gws.Error``: If no child layers could be created. 

74 """ 

75 

76 configs = _layer_configs_from_layer(layer, source_layers, create_leaf_layer_config) 

77 create_group_layers(layer, configs) 

78 return True 

79 

80 

81def create_group_layers(layer: core.Object, layer_configs: list): 

82 """Create child layers of a layer from their configs. 

83 

84 Each config gets the parent's WGS extent, resolutions and map CRS. The 

85 created layers replace ``layer.layers``. 

86 

87 Args: 

88 layer: The layer to create children for. 

89 layer_configs: Child layer configs. 

90 

91 Raises: 

92 ``gws.Error``: If no child layers could be created. 

93 """ 

94 

95 layer.layers = [] 

96 

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) 

107 

108 if not layer.layers: 

109 raise gws.Error(f'group is empty: {layer}') 

110 

111 

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.""" 

114 

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 ) 

126 

127 

128def _layer_configs_from_args(tca: _TreeConfigArgs) -> list[gws.Config]: 

129 """Generate a config tree from a list of source layers.""" 

130 

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) 

134 

135 # make configs... 

136 configs = gws.u.compact(_config(tca, sl, 0) for sl in roots) 

137 

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 ) 

144 

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) 

154 

155 if ctx.errors: 

156 raise gws.ConfigurationError(ctx.errors[0].message) 

157 

158 return layer_configs 

159 

160 

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 

166 

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 ) 

178 

179 for cc in tca.auto_layers: 

180 if gws.gis.source.layer_matches(sl, cc.applyTo): 

181 cfg = _deep_merge(cfg, cc.config) 

182 

183 return gws.u.compact(cfg) 

184 

185 

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 

191 

192 # leaf layer 

193 if not sl.isGroup: 

194 return tca.create_leaf_layer_config([sl]) 

195 

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]) 

201 

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) 

207 

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 } 

216 

217 

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) 

225 

226 if gws.u.is_list(x) and gws.u.is_list(y): 

227 return gws.u.compact(x) + gws.u.compact(y) 

228 

229 return y if y is not None else x