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

160 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Map related commands.""" 

2 

3from typing import Optional 

4 

5import gws 

6import gws.base.action 

7import gws.lib.bounds 

8import gws.lib.crs 

9import gws.lib.extent 

10import gws.gis.render 

11import gws.lib.image 

12import gws.lib.intl 

13import gws.lib.jsonx 

14import gws.lib.mime 

15import gws.lib.uom 

16 

17LEGEND_IMAGE_FORMAT = gws.ImageFormat(name='png', mimeTypes=['image/png'], options={}) 

18 

19 

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

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

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

23 

24 pass 

25 

26 

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

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

29 pass 

30 

31 

32class GetBoxRequest(gws.Request): 

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

34 

35 bbox: gws.Extent 

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

37 width: int 

38 """Image width in pixels.""" 

39 height: int 

40 """Image height in pixels.""" 

41 layerUid: str 

42 """Layer UID.""" 

43 crs: Optional[gws.CrsName] 

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

45 dpi: Optional[int] 

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

47 compositeLayerUids: Optional[list[str]] 

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

49 

50 

51class GetTileRequest(gws.Request): 

52 """Request for a layer tile.""" 

53 

54 layerUid: str 

55 """Layer UID.""" 

56 x: int 

57 """Tile column.""" 

58 y: int 

59 """Tile row.""" 

60 z: int 

61 """Zoom level.""" 

62 compositeLayerUids: Optional[list[str]] 

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

64 

65 

66class GetLegendRequest(gws.Request): 

67 """Request for a layer legend.""" 

68 

69 layerUid: str 

70 """Layer UID.""" 

71 

72 

73class ImageResponse(gws.Response): 

74 """Image response.""" 

75 

76 content: bytes 

77 """Encoded image.""" 

78 mimeType: str 

79 """MIME type of the image.""" 

80 

81 

82class DescribeLayerRequest(gws.Request): 

83 """Request for a layer description.""" 

84 

85 layerUid: str 

86 """Layer UID.""" 

87 

88 

89class DescribeLayerResponse(gws.Request): 

90 """Layer description response.""" 

91 

92 content: str 

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

94 

95 

96class GetFeaturesRequest(gws.Request): 

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

98 

99 bbox: Optional[gws.Extent] 

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

101 layerUid: str 

102 """Layer UID.""" 

103 modelUid: Optional[str] 

104 """Model UID; not used.""" 

105 crs: Optional[gws.CrsName] 

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

107 resolution: Optional[float] 

108 """Map resolution; not used.""" 

109 limit: int = 0 

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

111 views: Optional[list[str]] 

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

113 

114 

115class GetFeaturesResponse(gws.Response): 

116 """Features response.""" 

117 

118 features: list[gws.FeatureProps] 

119 """Found features.""" 

120 

121 

122_GET_FEATURES_LIMIT = 10000 

123_PIXEL_SIZE_LIMIT = 4096 

124 

125 

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

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

128 """Map action. 

129 

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

131 client. 

132 """ 

133 

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

135 

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

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

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

139 mime_type, content = self._get_box(req, p) 

140 return ImageResponse(mimeType=mime_type, content=content) 

141 

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

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

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

145 mime_type, content = self._get_box(req, p) 

146 return gws.ContentResponse(mimeType=mime_type, content=content) 

147 

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

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

150 """Render a layer tile.""" 

151 mime_type, content = self._get_tile(req, p) 

152 return ImageResponse(mimeType=mime_type, content=content) 

153 

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

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

156 """Render a layer tile.""" 

157 mime_type, content = self._get_tile(req, p) 

158 return gws.ContentResponse(mimeType=mime_type, content=content) 

159 

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

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

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

163 mime_type, content = self._get_legend(req, p) 

164 return ImageResponse(mimeType=mime_type, content=content) 

165 

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

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

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

169 mime_type, content = self._get_legend(req, p) 

170 return gws.ContentResponse(mimeType=mime_type, content=content) 

171 

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

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

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

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

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

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

178 

179 if not tpl: 

180 return DescribeLayerResponse(content='') 

181 

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

183 

184 return DescribeLayerResponse(content=res.content) 

185 

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

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

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

189 

190 propses = self._get_features(req, p) 

191 return GetFeaturesResponse(features=propses) 

192 

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

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

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

196 # @TODO the response should be geojson FeatureCollection 

197 

198 propses = self._get_features(req, p) 

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

200 

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

202 

203 ## 

204 

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

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

207 ok = ( 

208 0 < p.width <= _PIXEL_SIZE_LIMIT and 

209 0 < p.height <= _PIXEL_SIZE_LIMIT and 

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

211 ) # fmt: skip 

212 if not ok: 

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

214 

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

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

217 lri = gws.LayerRenderInput( 

218 type=gws.LayerRenderInputType.box, 

219 targetCrs=crs, 

220 user=req.user, 

221 extraParams={}, 

222 ) 

223 

224 bbox = p.bbox 

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

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

227 

228 if p.compositeLayerUids: 

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

230 

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

232 crs=crs, 

233 bbox=bbox, 

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

235 dpi=gws.lib.uom.OGC_SCREEN_PPI, 

236 rotation=0, 

237 ) 

238 

239 try: 

240 lro = layer.render(lri) 

241 if lro and lro.content: 

242 return gws.lib.mime.PNG, lro.content 

243 except Exception: 

244 gws.log.exception() 

245 

246 return self._empty_pixel 

247 

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

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

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

251 lri = gws.LayerRenderInput( 

252 type=gws.LayerRenderInputType.tile, 

253 targetCrs=layer.mapCrs, 

254 user=req.user, 

255 x=p.x, 

256 y=p.y, 

257 z=p.z, 

258 extraParams={}, 

259 ) 

260 if p.compositeLayerUids: 

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

262 lro = None 

263 

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

265 try: 

266 lro = layer.render(lri) 

267 except Exception: 

268 gws.log.exception() 

269 gws.debug.time_end() 

270 

271 if not lro: 

272 return self._empty_pixel 

273 

274 content = lro.content 

275 

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

277 # so they will be subsequently served directly by nginx 

278 

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

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

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

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

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

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

285 

286 return gws.lib.mime.PNG, content 

287 

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

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

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

291 lro = layer.render_legend() 

292 if lro and lro.image: 

293 mime_type = LEGEND_IMAGE_FORMAT.mimeTypes[0] 

294 return mime_type, lro.image.to_bytes(mime_type, LEGEND_IMAGE_FORMAT.options) 

295 return self._empty_pixel 

296 

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

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

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

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

301 

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

303 

304 bounds = layer.bounds 

305 if p.bbox: 

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

307 

308 search = gws.SearchQuery( 

309 bounds=bounds, 

310 project=project, 

311 layers=[layer], 

312 limit=_GET_FEATURES_LIMIT, 

313 ) 

314 

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

316 if not features: 

317 return [] 

318 

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

320 if tpl: 

321 for feature in features: 

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

323 

324 mc = gws.ModelContext( 

325 op=gws.ModelOperation.read, 

326 target=gws.ModelReadTarget.map, 

327 user=req.user, 

328 ) 

329 

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