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

1"""Base layer object.""" 

2 

3from typing import Optional, cast 

4 

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 

16 

17from . import ows 

18 

19DEFAULT_TILE_SIZE = 256 

20 

21 

22class CacheConfig(gws.Config): 

23 """Cache configuration""" 

24 

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

33 

34 

35class GridConfig(gws.Config): 

36 """Grid configuration for caches and tiled data""" 

37 

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

48 

49 

50class AutoLayersOptions(gws.ConfigWithAccess): 

51 """Configuration for automatic layers.""" 

52 

53 applyTo: Optional[gws.gis.source.LayerFilter] 

54 """Source layers to apply the configuration to.""" 

55 config: dict 

56 """Configuration for the matching layers.""" 

57 

58 

59class ClientOptions(gws.Data): 

60 """Client options for a layer.""" 

61 

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

76 

77 

78class GridProps(gws.Props): 

79 origin: str 

80 extent: gws.Extent 

81 resolutions: list[float] 

82 tileSize: int 

83 

84 

85class Config(gws.ConfigWithAccess): 

86 """Layer configuration""" 

87 

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

134 

135 

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 = '' 

154 

155 

156_DEFAULT_IMAGE_FORMAT = gws.lib.image.FormatConfig(mimeTypes=['image/png'], options={'mode': 'P'}) 

157 

158 

159class Object(gws.Layer): 

160 parent: gws.Layer 

161 

162 clientOptions: gws.LayerClientOptions 

163 cssSelector: str 

164 

165 canRenderBox = False 

166 canRenderSvg = False 

167 canRenderXyz = False 

168 

169 isEnabledForOws = False 

170 isGroup = False 

171 isSearchable = False 

172 

173 hasLegend = False 

174 

175 parentBounds: gws.Bounds 

176 parentResolutions: list[float] 

177 

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

185 

186 p = self.cfg('imageFormat') or _DEFAULT_IMAGE_FORMAT 

187 self.imageFormat = gws.ImageFormat(mimeTypes=p.mimeTypes, options=p.options or {}) 

188 

189 self.parentBounds = self.cfg('_parentBounds') 

190 self.parentResolutions = self.cfg('_parentResolutions') 

191 self.mapCrs = self.parentBounds.crs 

192 

193 self.bounds = self.parentBounds 

194 self.zoomBounds = cast(gws.Bounds, None) 

195 self.resolutions = self.parentResolutions 

196 

197 self.templates = [] 

198 self.models = [] 

199 self.finders = [] 

200 

201 self.metadata = gws.base.metadata.new() 

202 self.legend = None 

203 self.legendUrl = '' 

204 

205 self.layers = [] 

206 

207 self.grid = None 

208 self.cache = None 

209 self.ows = gws.LayerOws() 

210 

211 setattr(self, 'provider', None) 

212 self.sourceLayers = [] 

213 

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

229 

230 ## 

231 

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 

240 

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 

249 

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 

255 

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 

268 

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 

276 

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 

282 

283 def configure_models(self): 

284 return gws.config.util.configure_models_for(self) 

285 

286 def configure_provider(self): 

287 pass 

288 

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 

296 

297 def configure_search(self): 

298 if not self.cfg('withSearch'): 

299 return True 

300 return gws.config.util.configure_finders_for(self) 

301 

302 def configure_sources(self): 

303 pass 

304 

305 def configure_templates(self): 

306 return gws.config.util.configure_templates_for(self) 

307 

308 def configure_group_layers(self, layer_configs): 

309 ls = [] 

310 

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

318 

319 self.layers = gws.u.compact(ls) 

320 

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

324 

325 ## 

326 

327 def post_configure(self): 

328 self.isSearchable = bool(self.finders) 

329 self.hasLegend = bool(self.legend) 

330 

331 if self.bounds.crs != self.mapCrs: 

332 raise gws.Error(f'layer {self!r}: invalid CRS {self.bounds.crs}') 

333 

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) 

337 

338 self.wgsExtent = gws.lib.bounds.transform(self.bounds, gws.lib.crs.WGS84).extent 

339 self.zoomBounds = self.zoomBounds or self.bounds 

340 

341 if self.legend: 

342 self.legendUrl = self.url_path('legend') 

343 

344 ## 

345 

346 # @TODO use Node.find_ancestors 

347 

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 

355 

356 def descendants(self): 

357 ls = [] 

358 for la in self.layers: 

359 ls.append(la) 

360 ls.extend(la.descendants()) 

361 return ls 

362 

363 def url_path(self, kind): 

364 ext = gws.lib.mime.extension_for(self.imageFormat.mimeTypes[0]) 

365 url_path_suffix = '/gws.' + ext 

366 

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) 

376 

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 ) 

392 

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 ) 

400 

401 if self.displayMode == gws.LayerDisplayMode.tile: 

402 p.type = 'tile' 

403 p.url = self.url_path('tile') 

404 

405 if self.displayMode == gws.LayerDisplayMode.box: 

406 p.type = 'box' 

407 p.url = self.url_path('box') 

408 

409 return p 

410 

411 def render(self, lri): 

412 pass 

413 

414 def find_features(self, search, user): 

415 return [] 

416 

417 def render_legend(self, args=None) -> Optional[gws.LegendRenderOutput]: 

418 if not self.legend: 

419 return None 

420 

421 def _get(): 

422 out = self.legend.render() 

423 return out 

424 

425 if not args: 

426 return gws.u.get_server_global('legend_' + self.uid, _get) 

427 

428 return self.legend.render(args) 

429 

430 def mapproxy_config(self, mc): 

431 pass