Coverage for gws-app/gws/base/map/action.py: 60%

164 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""Map related commands.""" 

2 

3from typing import Optional 

4 

5import gws 

6import gws.base.action 

7import gws.base.legend 

8import gws.lib.bounds 

9import gws.lib.crs 

10import gws.lib.extent 

11import gws.gis.render 

12import gws.lib.image 

13import gws.lib.intl 

14import gws.lib.jsonx 

15import gws.lib.mime 

16import gws.lib.uom 

17 

18 

19@gws.ext.config.action('map') 

20class Config(gws.base.action.Config): 

21 """Serves map images, tiles, legends and features to the client.""" 

22 

23 pass 

24 

25 

26@gws.ext.props.action('map') 

27class Props(gws.base.action.Props): 

28 pass 

29 

30 

31class GetBoxRequest(gws.Request): 

32 """Request for a layer image of a bounding box.""" 

33 

34 bbox: gws.Extent 

35 """Bounding box in the request CRS.""" 

36 width: int 

37 """Image width in pixels.""" 

38 height: int 

39 """Image height in pixels.""" 

40 layerUid: str 

41 """Layer UID.""" 

42 crs: Optional[gws.CrsName] 

43 """CRS of the bounding box; defaults to the map CRS of the layer.""" 

44 dpi: Optional[int] 

45 """Image resolution; not used, the box is always rendered at the OGC screen resolution.""" 

46 compositeLayerUids: Optional[list[str]] 

47 """UIDs of the child layers to render in one composite image, passed to the layer in ``extraParams``.""" 

48 

49 

50class GetTileRequest(gws.Request): 

51 """Request for a layer tile.""" 

52 

53 layerUid: str 

54 """Layer UID.""" 

55 x: int 

56 """Tile column.""" 

57 y: int 

58 """Tile row.""" 

59 z: int 

60 """Zoom level.""" 

61 compositeLayerUids: Optional[list[str]] 

62 """UIDs of the child layers to render in one composite image, passed to the layer in ``extraParams``.""" 

63 

64 

65class GetLegendRequest(gws.Request): 

66 """Request for a layer legend.""" 

67 

68 layerUid: str 

69 """Layer UID.""" 

70 

71 

72class ImageResponse(gws.Response): 

73 """Image response.""" 

74 

75 content: bytes 

76 """Encoded image.""" 

77 mimeType: str 

78 """MIME type of the image.""" 

79 

80 

81class DescribeLayerRequest(gws.Request): 

82 """Request for a layer description.""" 

83 

84 layerUid: str 

85 """Layer UID.""" 

86 

87 

88class DescribeLayerResponse(gws.Response): 

89 """Layer description response.""" 

90 

91 content: str 

92 """Rendered ``layer.description`` template, or an empty string.""" 

93 

94 

95class GetFeaturesRequest(gws.Request): 

96 """Request for the features of a layer.""" 

97 

98 bbox: Optional[gws.Extent] 

99 """Bounding box to search in; defaults to the layer bounds.""" 

100 layerUid: str 

101 """Layer UID.""" 

102 modelUid: Optional[str] 

103 """Model UID; not used.""" 

104 crs: Optional[gws.CrsName] 

105 """CRS of the bounding box; defaults to the map CRS of the layer.""" 

106 resolution: Optional[float] 

107 """Map resolution; not used.""" 

108 limit: int = 0 

109 """Maximum number of features; not used, the limit is fixed at 10000.""" 

110 views: Optional[list[str]] 

111 """Feature views to render; not used, only the label view is rendered.""" 

112 

113 

114class GetFeaturesResponse(gws.Response): 

115 """Features response.""" 

116 

117 features: list[gws.FeatureProps] 

118 """Found features.""" 

119 

120 

121_GET_FEATURES_LIMIT = 10000 

122_PIXEL_SIZE_LIMIT = 4096 

123 

124 

125@gws.ext.object.action('map') 

126class Object(gws.base.action.Object): 

127 """Map action. 

128 

129 Serves layer images, tiles, legends, descriptions and features to the 

130 client. 

131 """ 

132 

133 _empty_pixel = gws.lib.mime.PNG, gws.lib.image.util.empty_pixel() 

134 

135 @gws.ext.command.api('mapGetBox') 

136 def api_get_box(self, req: gws.WebRequester, p: GetBoxRequest) -> ImageResponse: 

137 """Render a layer image of a bounding box.""" 

138 mime, content = self._get_box(req, p) 

139 return ImageResponse(mimeType=mime, content=content) 

140 

141 @gws.ext.command.get('mapGetBox') 

142 def http_get_box(self, req: gws.WebRequester, p: GetBoxRequest) -> gws.ContentResponse: 

143 """Render a layer image of a bounding box.""" 

144 mime, content = self._get_box(req, p) 

145 return gws.ContentResponse(mimeType=mime, content=content) 

146 

147 @gws.ext.command.api('mapGetTile') 

148 def api_get_tile(self, req: gws.WebRequester, p: GetTileRequest) -> ImageResponse: 

149 """Render a layer tile.""" 

150 mime, content = self._get_tile(req, p) 

151 return ImageResponse(mimeType=mime, content=content) 

152 

153 @gws.ext.command.get('mapGetTile') 

154 def http_get_tile(self, req: gws.WebRequester, p: GetTileRequest) -> gws.ContentResponse: 

155 """Render a layer tile.""" 

156 mime, content = self._get_tile(req, p) 

157 return gws.ContentResponse(mimeType=mime, content=content) 

158 

159 @gws.ext.command.api('mapGetLegend') 

160 def api_get_legend(self, req: gws.WebRequester, p: GetLegendRequest) -> ImageResponse: 

161 """Render the legend of a layer.""" 

162 mime, content = self._get_legend(req, p) 

163 return ImageResponse(mimeType=mime, content=content) 

164 

165 @gws.ext.command.get('mapGetLegend') 

166 def http_get_legend(self, req: gws.WebRequester, p: GetLegendRequest) -> gws.ContentResponse: 

167 """Render the legend of a layer.""" 

168 mime, content = self._get_legend(req, p) 

169 return gws.ContentResponse(mimeType=mime, content=content) 

170 

171 @gws.ext.command.api('mapDescribeLayer') 

172 def describe_layer(self, req: gws.WebRequester, p: DescribeLayerRequest) -> DescribeLayerResponse: 

173 """Return the description of a layer.""" 

174 project = req.user.require_project(p.projectUid) 

175 layer = req.user.require_layer(p.layerUid) 

176 tpl = self.root.app.templateMgr.find_template('layer.description', where=[layer, project], user=req.user) 

177 

178 if not tpl: 

179 return DescribeLayerResponse(content='') 

180 

181 res = tpl.render(gws.TemplateRenderInput(args={'layer': layer}, locale=gws.lib.intl.locale(p.localeUid, project.localeUids), user=req.user)) 

182 

183 return DescribeLayerResponse(content=res.content) 

184 

185 @gws.ext.command.api('mapGetFeatures') 

186 def api_get_features(self, req: gws.WebRequester, p: GetFeaturesRequest) -> GetFeaturesResponse: 

187 """Return the features of a layer in a bounding box.""" 

188 

189 propses = self._get_features(req, p) 

190 return GetFeaturesResponse(features=propses) 

191 

192 @gws.ext.command.get('mapGetFeatures') 

193 def http_get_features(self, req: gws.WebRequester, p: GetFeaturesRequest) -> gws.ContentResponse: 

194 """Return the features of a layer in a bounding box.""" 

195 # @TODO the response should be geojson FeatureCollection 

196 

197 propses = self._get_features(req, p) 

198 js = gws.lib.jsonx.to_string({'features': propses}) 

199 

200 return gws.ContentResponse(mimeType=gws.lib.mime.JSON, content=js) 

201 

202 ## 

203 

204 def _get_box(self, req: gws.WebRequester, p: GetBoxRequest): 

205 """Render a box and return the MIME type and the content.""" 

206 ok = ( 

207 0 < p.width <= _PIXEL_SIZE_LIMIT and 

208 0 < p.height <= _PIXEL_SIZE_LIMIT and 

209 p.width * p.height <= _PIXEL_SIZE_LIMIT * _PIXEL_SIZE_LIMIT 

210 ) # fmt: skip 

211 if not ok: 

212 raise gws.BadRequestError(f'invalid image size') 

213 

214 layer = req.user.require_layer(p.layerUid) 

215 crs = gws.lib.crs.get(p.crs) or layer.mapCrs 

216 lri = gws.LayerRenderInput( 

217 type=gws.LayerRenderInputType.box, 

218 targetCrs=crs, 

219 user=req.user, 

220 extraParams={}, 

221 ) 

222 

223 bbox = p.bbox 

224 if crs.isYX and req.param('service') == 'WMS' and req.param('version', '').startswith('1.3'): 

225 bbox = gws.lib.extent.swap_xy(bbox) 

226 

227 if p.compositeLayerUids: 

228 lri.extraParams['compositeLayerUids'] = p.compositeLayerUids 

229 

230 lri.view = gws.gis.render.map_view_from_bbox( 

231 crs=crs, 

232 bbox=bbox, 

233 size=(p.width, p.height, gws.Uom.px), 

234 dpi=gws.lib.uom.OGC_SCREEN_PPI, 

235 rotation=0, 

236 ) 

237 

238 try: 

239 lro = layer.render(lri) 

240 if lro and lro.content: 

241 return _mime_type(layer), lro.content 

242 except Exception: 

243 gws.log.exception() 

244 

245 return self._empty_pixel 

246 

247 def _get_tile(self, req: gws.WebRequester, p: GetTileRequest): 

248 """Render a tile and return the MIME type and the content.""" 

249 layer = req.user.require_layer(p.layerUid) 

250 lri = gws.LayerRenderInput( 

251 type=gws.LayerRenderInputType.tile, 

252 targetCrs=layer.mapCrs, 

253 user=req.user, 

254 x=p.x, 

255 y=p.y, 

256 z=p.z, 

257 extraParams={}, 

258 ) 

259 if p.compositeLayerUids: 

260 lri.extraParams['compositeLayerUids'] = p.compositeLayerUids 

261 lro = None 

262 

263 gws.debug.time_start(f'RENDER_TILE layer={p.layerUid} lri={lri!r}') 

264 try: 

265 lro = layer.render(lri) 

266 except Exception: 

267 gws.log.exception() 

268 gws.debug.time_end() 

269 

270 if not lro: 

271 return self._empty_pixel 

272 

273 content = lro.content 

274 

275 # for public tiled layers, write tiles to the web cache 

276 # so they will be subsequently served directly by nginx 

277 

278 # if content and gws.u.is_public_object(layer) and layer.has_cache: 

279 # path = layer.url_path_for('tile') 

280 # path = path.replace('{x}', str(p.x)) 

281 # path = path.replace('{y}', str(p.y)) 

282 # path = path.replace('{z}', str(p.z)) 

283 # gws.gis.cache.store_in_web_cache(path, content) 

284 

285 return _mime_type(layer), content 

286 

287 def _get_legend(self, req: gws.WebRequester, p: GetLegendRequest): 

288 """Render a legend and return the MIME type and the content.""" 

289 layer = req.user.require_layer(p.layerUid) 

290 lro = layer.render_legend() 

291 if lro and lro.image: 

292 mime = gws.base.legend.IMAGE_FORMAT.mimeTypes[0] 

293 return mime, lro.image.to_bytes(mime, gws.base.legend.IMAGE_FORMAT.options) 

294 return self._empty_pixel 

295 

296 def _get_features(self, req: gws.WebRequester, p: GetFeaturesRequest) -> list[gws.FeatureProps]: 

297 """Find layer features in the bounding box and convert them to view props.""" 

298 layer = req.user.require_layer(p.layerUid) 

299 project = layer.find_closest(gws.ext.object.project) 

300 

301 crs = gws.lib.crs.get(p.crs) or layer.mapCrs 

302 

303 bounds = layer.bounds 

304 if p.bbox: 

305 bounds = gws.lib.bounds.from_extent(p.bbox, crs) 

306 

307 search = gws.SearchQuery( 

308 bounds=bounds, 

309 project=project, 

310 layers=[layer], 

311 limit=_GET_FEATURES_LIMIT, 

312 ) 

313 

314 features = layer.find_features(search, req.user) 

315 if not features: 

316 return [] 

317 

318 tpl = self.root.app.templateMgr.find_template(f'feature.label', where=[layer, project], user=req.user) 

319 if tpl: 

320 for feature in features: 

321 feature.render_views([tpl], project=project, layer=layer, user=req.user) 

322 

323 mc = gws.ModelContext( 

324 op=gws.ModelOperation.read, 

325 target=gws.ModelReadTarget.map, 

326 user=req.user, 

327 ) 

328 

329 return [f.model.feature_to_view_props(f, mc) for f in features] 

330 

331 

332def _mime_type(layer: gws.Layer) -> str: 

333 if layer.imageFormat and layer.imageFormat.mimeTypes: 

334 return layer.imageFormat.mimeTypes[0] 

335 return gws.lib.mime.PNG