Coverage for gws-app/gws/base/layer/core.py: 75%

254 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +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.base.legend 

14import gws.lib.mime 

15import gws.lib.image 

16import gws.gis.cache 

17 

18from . import ows 

19 

20DEFAULT_IMAGE_FORMAT = gws.lib.image.format.PNG8 

21 

22 

23class AutoLayersConfig(gws.ConfigWithAccess): 

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

25 

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

27 """Filter for the source layers the configuration applies to.""" 

28 config: dict 

29 """Layer configuration to merge into each matching layer.""" 

30 

31 

32class ClientConfig(gws.Data): 

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

34 

35 expanded: bool = False 

36 """Show the layer's children expanded in the layer tree.""" 

37 unlisted: bool = False 

38 """Hide the layer from the layer tree.""" 

39 selected: bool = False 

40 """Select the layer in the layer tree initially.""" 

41 hidden: bool = False 

42 """Hide the layer on the map initially.""" 

43 unfolded: bool = False 

44 """Show the layer's children in the tree instead of the layer itself.""" 

45 exclusive: bool = False 

46 """Allow only one child layer to be visible at a time.""" 

47 treeClassName = '' 

48 """CSS class for the layer's item in the layer tree.""" 

49 

50 

51class ClientOptions(gws.Data): 

52 """Client options for a layer.""" 

53 

54 exclusive: bool 

55 """Only one of this layer children is visible at a time.""" 

56 expanded: bool 

57 """A layer is expanded in the list view.""" 

58 hidden: bool 

59 """A layer is initially hidden.""" 

60 selected: bool 

61 """A layer is initially selected.""" 

62 treeClassName: str 

63 """CSS class name for the layer tree item.""" 

64 unfolded: bool 

65 """A layer is not listed, but its children are.""" 

66 unlisted: bool 

67 """A layer is hidden in the list view.""" 

68 

69 

70class Config(gws.ConfigWithAccess): 

71 """Layer configuration""" 

72 

73 cache: Optional[gws.gis.cache.LayerConfig] 

74 """Tile cache options.""" 

75 clientOptions: Optional[ClientConfig] 

76 """Display options in the client layer tree.""" 

77 cssSelector: str = '' 

78 """CSS selector for styling the layer's features in the client.""" 

79 display: gws.LayerDisplayMode = gws.LayerDisplayMode.box 

80 """The way the layer is rendered in the client.""" 

81 extent: Optional[gws.Extent] 

82 """Layer extent in map CRS coordinates, clipped to the parent extent.""" 

83 zoomExtent: Optional[gws.Extent] 

84 """Extent to zoom to when zooming to the layer.""" 

85 extentBuffer: Optional[int] 

86 """Ignored. (deprecated in 8.5)""" 

87 finders: Optional[list[gws.ext.config.finder]] 

88 """Search providers for the layer's features.""" 

89 grid: Optional[dict] 

90 """Ignored. (deprecated in 8.5)""" 

91 imageFormat: Optional[gws.lib.image.format.Config] 

92 """Format of rendered and cached images.""" 

93 legend: Optional[gws.ext.config.legend] 

94 """Custom legend or legend options for the layer.""" 

95 loadingStrategy: gws.FeatureLoadingStrategy = gws.FeatureLoadingStrategy.all 

96 """When the client loads the layer's features.""" 

97 metadata: Optional[gws.base.metadata.Config] 

98 """Layer metadata.""" 

99 models: Optional[list[gws.ext.config.model]] 

100 """Data models for the layer's features.""" 

101 opacity: float = 1 

102 """Layer opacity, from 0 to 1.""" 

103 ows: Optional[ows.Config] 

104 """Layer names, namespace and allowed services for OWS services.""" 

105 templates: Optional[list[gws.ext.config.template]] 

106 """Templates for the layer's description and feature info.""" 

107 title: str = '' 

108 """Layer title, shown in the layer tree.""" 

109 zoom: Optional[gws.gis.zoom.Config] 

110 """Scales or zoom levels at which the layer is visible.""" 

111 withSearch: Optional[bool] = True 

112 """Include the layer in searches.""" 

113 withLegend: Optional[bool] = True 

114 """Show a legend for the layer.""" 

115 withCache: Optional[bool] = True 

116 """Store rendered tiles in the persistent cache. (changed in 8.5)""" 

117 withOws: Optional[bool] = True 

118 """Publish the layer in OWS services.""" 

119 

120 

121class Props(gws.Props): 

122 clientOptions: ClientOptions 

123 cssSelector: str 

124 displayMode: str 

125 extent: Optional[gws.Extent] 

126 zoomExtent: Optional[gws.Extent] 

127 geometryType: Optional[gws.GeometryType] 

128 grid: gws.lib.grid.Props 

129 layers: Optional[list['Props']] 

130 loadingStrategy: gws.FeatureLoadingStrategy 

131 metadata: gws.base.metadata.Props 

132 model: Optional[gws.base.model.Props] 

133 opacity: Optional[float] 

134 resolutions: Optional[list[float]] 

135 title: str = '' 

136 type: str 

137 uid: str 

138 url: str = '' 

139 

140 

141class Object(gws.Layer): 

142 """Base layer. 

143 

144 Provides the attributes common to all layers, the configuration protocol 

145 ``configure_layer``, client props and the URLs of the map action commands. 

146 Subclasses call ``configure_layer`` from their ``configure`` method, 

147 override the ``configure_*`` steps they need and implement ``render_box``, 

148 ``render_tile`` or ``render_svg`` for the outputs they support. 

149 """ 

150 

151 parent: gws.Layer 

152 """Parent layer.""" 

153 

154 clientOptions: ClientOptions 

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

156 

157 canRenderBox = False 

158 canRenderSvg = False 

159 canRenderTile = False 

160 canRenderInClient = False 

161 

162 isEnabledForOws = False 

163 isGroup = False 

164 isSearchable = False 

165 

166 hasLegend = False 

167 

168 parentWgsExtent: gws.Extent 

169 """WGS84 extent of the parent layer or map.""" 

170 parentResolutions: list[float] 

171 """Resolutions of the parent layer or map.""" 

172 

173 def configure(self): 

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

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

176 if self.cfg('extentBuffer') is not None: 

177 self.root.config_warning('"layer.extentBuffer" is deprecated and ignored') 

178 

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

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

181 

182 self.displayMode = self.cfg('display') 

183 if self.displayMode == gws.LayerDisplayMode.client and not self.canRenderInClient: 

184 raise gws.ConfigurationError(f'display mode "client" is not supported for this layer') 

185 

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

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

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

189 

190 self.imageFormat = DEFAULT_IMAGE_FORMAT 

191 p = self.cfg('imageFormat') 

192 if p: 

193 self.imageFormat = gws.lib.image.format.from_config(p) 

194 

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

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

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

198 

199 self.wgsExtent = self.parentWgsExtent 

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

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

202 self.resolutions = self.parentResolutions 

203 

204 self.templates = [] 

205 self.models = [] 

206 self.finders = [] 

207 

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

209 self.legend = None 

210 

211 self.layers = [] 

212 self.sourceLayers = [] 

213 

214 self.grabbers = {} 

215 

216 def configure_layer(self): 

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

218 

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

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

221 """ 

222 

223 self.configure_provider() 

224 self.configure_sources() 

225 self.configure_group() 

226 self.configure_models() 

227 self.configure_extent() 

228 self.configure_bounds() 

229 self.configure_zoom_bounds() 

230 self.configure_resolutions() 

231 self.configure_legend() 

232 self.configure_metadata() 

233 self.configure_templates() 

234 self.configure_search() 

235 self.configure_ows() 

236 

237 ## 

238 

239 def configure_group(self): 

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

241 

242 Returns: 

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

244 """ 

245 pass 

246 

247 def configure_extent(self): 

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

249 

250 Returns: 

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

252 

253 Raises: 

254 ``gws.ConfigurationError``: If the configured extent is invalid. 

255 """ 

256 p = self.cfg('extent') 

257 if p: 

258 ext = gws.lib.extent.from_list(p) 

259 if not ext or not gws.lib.extent.is_valid(ext): 

260 raise gws.ConfigurationError(f'invalid extent {p!r}') 

261 self.wgsExtent = gws.lib.extent.transform_to_wgs(ext, self.mapCrs) 

262 return True 

263 

264 def configure_bounds(self): 

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

266 

267 The bounds are the WGS extent clipped to the parent extent and to the 

268 CRS. If the extent lies outside of the parent extent, a configuration 

269 warning is issued and the parent extent is used. 

270 

271 Returns: 

272 ``True``. 

273 

274 Raises: 

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

276 """ 

277 

278 extent = gws.lib.extent.intersection(self.wgsExtent, self.parentWgsExtent) 

279 if extent: 

280 extent = self.mapCrs.clip_wgs_extent(extent) 

281 if extent is None: 

282 self.root.config_warning(f'layer extent outside of the parent extent wgs={self.wgsExtent}') 

283 extent = self.mapCrs.clip_wgs_extent(self.parentWgsExtent) 

284 if extent is None: 

285 raise gws.ConfigurationError(f'layer extent could not be determined wgs={self.wgsExtent}') 

286 self.bounds = gws.Bounds( 

287 crs=self.mapCrs, 

288 extent=gws.lib.extent.transform_from_wgs(extent, self.mapCrs), 

289 ) 

290 return True 

291 

292 def configure_zoom_bounds(self): 

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

294 

295 Returns: 

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

297 

298 Raises: 

299 ``gws.ConfigurationError``: If the configured zoom extent is invalid. 

300 """ 

301 p = self.cfg('zoomExtent') 

302 if p: 

303 ext = gws.lib.extent.from_list(p) 

304 if not ext or not gws.lib.extent.is_valid(ext): 

305 raise gws.ConfigurationError(f'invalid extent {p!r}') 

306 self.zoomBounds = gws.Bounds(crs=self.mapCrs, extent=ext) 

307 return True 

308 

309 def configure_legend(self): 

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

311 

312 Returns: 

313 ``True`` if a legend is configured or legends are disabled with 

314 ``withLegend``, ``None`` otherwise. 

315 """ 

316 if not self.cfg('withLegend'): 

317 return True 

318 p = self.cfg('legend') 

319 if p: 

320 self.legend = self.create_child(gws.ext.object.legend, p) 

321 return True 

322 

323 def configure_metadata(self): 

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

325 

326 Returns: 

327 ``True`` if metadata is configured, ``None`` otherwise. 

328 """ 

329 p = self.cfg('metadata') 

330 if p: 

331 self.metadata = gws.base.metadata.from_config(p) 

332 return True 

333 

334 def configure_models(self): 

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

336 

337 Returns: 

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

339 """ 

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

341 

342 def configure_provider(self): 

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

344 

345 Returns: 

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

347 """ 

348 pass 

349 

350 def configure_resolutions(self): 

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

352 

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

354 

355 Returns: 

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

357 

358 Raises: 

359 ``gws.Error``: If the zoom configuration yields no resolutions. 

360 """ 

361 p = self.cfg('zoom') 

362 if p: 

363 gws.gis.zoom.warn_deprecated_options(p, self.root) 

364 self.resolutions = gws.gis.zoom.resolutions_for_layer(p, self.cfg('_parentResolutions'), self.mapCrs) 

365 if not self.resolutions: 

366 raise gws.Error(f'layer {self!r}: no resolutions, config={p!r} parent={self.parentResolutions!r}') 

367 return True 

368 

369 def configure_search(self): 

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

371 

372 Returns: 

373 ``True`` if finders are configured or search is disabled with 

374 ``withSearch``, ``False`` otherwise. 

375 """ 

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

377 return True 

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

379 

380 def configure_sources(self): 

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

382 

383 Returns: 

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

385 """ 

386 pass 

387 

388 def configure_templates(self): 

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

390 

391 Returns: 

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

393 """ 

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

395 

396 def configure_ows(self): 

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

398 

399 The OWS layer and feature names default to the layer title converted 

400 to a UID. 

401 """ 

402 self.isEnabledForOws = self.cfg('withOws', default=True) 

403 self.ows = self.create_child(ows.Object, self.cfg('ows'), _defaultName=gws.u.to_uid(self.title)) 

404 

405 ## 

406 

407 def post_configure(self): 

408 self.isSearchable = bool(self.finders) 

409 self.hasLegend = bool(self.legend) 

410 

411 self.zoomBounds = self.zoomBounds or self.bounds 

412 

413 ## 

414 

415 def url_path_for(self, kind): 

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

417 url_path_suffix = '/gws.' + ext 

418 

419 # layer urls, handled by the map action (base/map/action.py) 

420 if kind == 'box': 

421 return gws.u.action_url_path('mapGetBox', layerUid=self.uid) + url_path_suffix 

422 if kind == 'tile': 

423 return gws.u.action_url_path('mapGetTile', layerUid=self.uid) + '/z/{z}/x/{x}/y/{y}' + url_path_suffix 

424 if kind == 'legend': 

425 legend_ext = gws.lib.mime.extension_for(gws.base.legend.IMAGE_FORMAT.mimeTypes[0]) 

426 return gws.u.action_url_path('mapGetLegend', layerUid=self.uid) + '/gws.' + legend_ext 

427 if kind == 'features': 

428 return gws.u.action_url_path('mapGetFeatures', layerUid=self.uid) 

429 raise gws.Error(f'invalid argument {kind=}') 

430 

431 def props(self, user): 

432 p = Props( 

433 clientOptions=self.clientOptions, 

434 cssSelector=self.cssSelector, 

435 displayMode=self.displayMode, 

436 extent=self.bounds.extent, 

437 zoomExtent=self.zoomBounds.extent, 

438 layers=self.layers, 

439 loadingStrategy=self.loadingStrategy, 

440 metadata=gws.base.metadata.props(self.metadata), 

441 opacity=self.opacity, 

442 resolutions=sorted(self.resolutions, reverse=True), 

443 title=self.title, 

444 uid=self.uid, 

445 ) 

446 

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

448 if gr: 

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

450 

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

452 p.type = 'tile' 

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

454 

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

456 p.type = 'box' 

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

458 

459 return p 

460 

461 def grabber_for(self, lri: gws.LayerRenderInput) -> Optional[gws.Grabber]: 

462 """Find the grabber for the target CRS of a render request. 

463 

464 Args: 

465 lri: Render input; without ``targetCrs`` the map CRS is used. 

466 

467 Returns: 

468 The grabber, or ``None`` if there is none for the CRS. 

469 """ 

470 crs = lri.targetCrs or self.mapCrs 

471 return self.grabbers.get(crs.srid) 

472 

473 def find_features(self, search, user): 

474 return [] 

475 

476 def render(self, lri): 

477 if lri.type == gws.LayerRenderInputType.box: 

478 return self.render_box(lri) 

479 if lri.type == gws.LayerRenderInputType.tile: 

480 return self.render_tile(lri) 

481 if lri.type == gws.LayerRenderInputType.svg: 

482 return self.render_svg(lri) 

483 

484 def render_box(self, lri): 

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

486 

487 Args: 

488 lri: Render input with the map view. 

489 

490 Returns: 

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

492 """ 

493 pass 

494 

495 def render_tile(self, lri): 

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

497 

498 Args: 

499 lri: Render input with the tile coordinates. 

500 

501 Returns: 

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

503 """ 

504 pass 

505 

506 def render_svg(self, lri): 

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

508 

509 Args: 

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

511 

512 Returns: 

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

514 """ 

515 pass 

516 

517 def render_legend(self, args=None): 

518 if not self.legend: 

519 return 

520 

521 legend = self.legend 

522 if not args: 

523 return gws.u.get_server_global('legend_' + self.uid, legend.render) 

524 return legend.render(args)