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

1"""Base layer object and the configuration shared by all layers.""" 

2 

3from typing import Optional, cast 

4 

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 

16 

17from . import ows 

18 

19DEFAULT_IMAGE_FORMAT = gws.ImageFormat(name='png8', mimeTypes=['image/png'], options={'mode': 'P'}) 

20 

21 

22class AutoLayersConfig(gws.ConfigWithAccess): 

23 """Configuration merged into automatically created layers that match a filter.""" 

24 

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

29 

30 

31class ClientConfig(gws.Data): 

32 """Display options for the layer in the client layer tree.""" 

33 

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

48 

49 

50class ClientOptions(gws.Data): 

51 """Client options for a layer.""" 

52 

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

67 

68 

69class Config(gws.ConfigWithAccess): 

70 """Layer configuration""" 

71 

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

118 

119 

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

138 

139 

140class Object(gws.Layer): 

141 """Base layer. 

142 

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

149 

150 parent: gws.Layer 

151 """Parent layer.""" 

152 

153 clientOptions: ClientOptions 

154 """Display options for the client layer tree.""" 

155 

156 canRenderBox = False 

157 canRenderSvg = False 

158 canRenderTile = False 

159 canRenderInClient = False 

160 

161 isEnabledForOws = False 

162 isGroup = False 

163 isSearchable = False 

164 

165 hasLegend = False 

166 

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

171 

172 def configure(self): 

173 if self.cfg('grid') is not None: 

174 self.root.config_warning('"layer.grid" is deprecated and ignored') 

175 

176 self.clientOptions = self.cfg('clientOptions') or gws.Data() 

177 self.cssSelector = self.cfg('cssSelector') 

178 

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

182 

183 self.loadingStrategy = self.cfg('loadingStrategy') 

184 self.opacity = self.cfg('opacity') 

185 self.title = self.cfg('title') 

186 

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

191 

192 self.parentWgsExtent = self.cfg('_parentWgsExtent') 

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

194 self.mapCrs = self.cfg('_mapCrs') 

195 

196 self.wgsExtent = self.parentWgsExtent 

197 self.bounds = cast(gws.Bounds, None) 

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

199 self.resolutions = self.parentResolutions 

200 

201 self.templates = [] 

202 self.models = [] 

203 self.finders = [] 

204 

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

206 self.legend = None 

207 

208 self.layers = [] 

209 self.sourceLayers = [] 

210 

211 self.grabbers = {} 

212 

213 def configure_layer(self): 

214 """Run the layer configuration steps in a fixed order. 

215 

216 The steps are provider, sources, group, models, extent, bounds, zoom 

217 bounds, resolutions, legend, metadata, templates, search and OWS. 

218 """ 

219 

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

233 

234 ## 

235 

236 def configure_group(self): 

237 """Create child layers. Does nothing in the base class. 

238 

239 Returns: 

240 ``True`` if child layers were created, ``None`` otherwise. 

241 """ 

242 pass 

243 

244 def configure_extent(self): 

245 """Set ``wgsExtent`` from the configured ``extent``. 

246 

247 Returns: 

248 ``True`` if an extent is configured, ``None`` otherwise. 

249 

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 

260 

261 def configure_bounds(self): 

262 """Set ``bounds`` in the map CRS. 

263 

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. 

267 

268 Returns: 

269 ``True``. 

270 

271 Raises: 

272 ``gws.ConfigurationError``: If no extent can be determined. 

273 """ 

274 

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 

288 

289 def configure_zoom_bounds(self): 

290 """Set ``zoomBounds`` from the configured ``zoomExtent``. 

291 

292 Returns: 

293 ``True`` if a zoom extent is configured, ``None`` otherwise. 

294 

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 

305 

306 def configure_legend(self): 

307 """Create the legend from the ``legend`` configuration. 

308 

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 

319 

320 def configure_metadata(self): 

321 """Set ``metadata`` from the ``metadata`` configuration. 

322 

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 

330 

331 def configure_models(self): 

332 """Create the models from the ``models`` configuration. 

333 

334 Returns: 

335 ``True`` if models are configured, ``False`` otherwise. 

336 """ 

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

338 

339 def configure_provider(self): 

340 """Set up the data provider. Does nothing in the base class. 

341 

342 Returns: 

343 ``True`` if a provider was configured, ``None`` otherwise. 

344 """ 

345 pass 

346 

347 def configure_resolutions(self): 

348 """Set ``resolutions`` from the ``zoom`` configuration. 

349 

350 Without a ``zoom`` configuration the layer keeps the parent resolutions. 

351 

352 Returns: 

353 ``True`` if a zoom configuration is present, ``None`` otherwise. 

354 

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 

365 

366 def configure_search(self): 

367 """Create the search providers from the ``finders`` configuration. 

368 

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) 

376 

377 def configure_sources(self): 

378 """Set up the source layers. Does nothing in the base class. 

379 

380 Returns: 

381 ``True`` if sources were configured, ``None`` otherwise. 

382 """ 

383 pass 

384 

385 def configure_templates(self): 

386 """Create the templates from the ``templates`` configuration. 

387 

388 Returns: 

389 ``True`` if at least one template was created, ``False`` otherwise. 

390 """ 

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

392 

393 def configure_ows(self): 

394 """Create the OWS binding and set ``isEnabledForOws`` from ``withOws``. 

395 

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

401 

402 ## 

403 

404 def post_configure(self): 

405 self.isSearchable = bool(self.finders) 

406 self.hasLegend = bool(self.legend) 

407 

408 self.zoomBounds = self.zoomBounds or self.bounds 

409 

410 ## 

411 

412 def url_path_for(self, kind): 

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

414 url_path_suffix = '/gws.' + ext 

415 

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

426 

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 ) 

442 

443 gr = self.grabbers.get(self.mapCrs.srid) 

444 if gr: 

445 p.grid = gws.lib.grid.props_for_resolutions(gr.grid, self.resolutions) 

446 

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

448 p.type = 'tile' 

449 p.url = self.url_path_for('tile') 

450 

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

452 p.type = 'box' 

453 p.url = self.url_path_for('box') 

454 

455 return p 

456 

457 def find_features(self, search, user): 

458 return [] 

459 

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) 

467 

468 def render_box(self, lri): 

469 """Render the layer as a single image. Returns nothing in the base class. 

470 

471 Args: 

472 lri: Render input with the map view. 

473 

474 Returns: 

475 Render output with the encoded image, or ``None``. 

476 """ 

477 pass 

478 

479 def render_tile(self, lri): 

480 """Render a single tile. Returns nothing in the base class. 

481 

482 Args: 

483 lri: Render input with the tile coordinates. 

484 

485 Returns: 

486 Render output with the encoded tile, or ``None``. 

487 """ 

488 pass 

489 

490 def render_svg(self, lri): 

491 """Render the layer as SVG. Returns nothing in the base class. 

492 

493 Args: 

494 lri: Render input with the map view and style. 

495 

496 Returns: 

497 Render output with SVG tags, or ``None``. 

498 """ 

499 pass 

500 

501 def render_legend(self, args=None): 

502 if not self.legend: 

503 return 

504 

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)