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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""Map related commands."""
3from typing import Optional
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
17LEGEND_IMAGE_FORMAT = gws.ImageFormat(name='png', mimeTypes=['image/png'], options={})
20@gws.ext.config.action('map')
21class Config(gws.base.action.Config):
22 """Serves map images, tiles, legends and features to the client."""
24 pass
27@gws.ext.props.action('map')
28class Props(gws.base.action.Props):
29 pass
32class GetBoxRequest(gws.Request):
33 """Request for a layer image of a bounding box."""
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``."""
51class GetTileRequest(gws.Request):
52 """Request for a layer tile."""
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``."""
66class GetLegendRequest(gws.Request):
67 """Request for a layer legend."""
69 layerUid: str
70 """Layer UID."""
73class ImageResponse(gws.Response):
74 """Image response."""
76 content: bytes
77 """Encoded image."""
78 mimeType: str
79 """MIME type of the image."""
82class DescribeLayerRequest(gws.Request):
83 """Request for a layer description."""
85 layerUid: str
86 """Layer UID."""
89class DescribeLayerResponse(gws.Request):
90 """Layer description response."""
92 content: str
93 """Rendered ``layer.description`` template, or an empty string."""
96class GetFeaturesRequest(gws.Request):
97 """Request for the features of a layer."""
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."""
115class GetFeaturesResponse(gws.Response):
116 """Features response."""
118 features: list[gws.FeatureProps]
119 """Found features."""
122_GET_FEATURES_LIMIT = 10000
123_PIXEL_SIZE_LIMIT = 4096
126@gws.ext.object.action('map')
127class Object(gws.base.action.Object):
128 """Map action.
130 Serves layer images, tiles, legends, descriptions and features to the
131 client.
132 """
134 _empty_pixel = gws.lib.mime.PNG, gws.lib.image.empty_pixel()
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)
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)
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)
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)
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)
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)
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)
179 if not tpl:
180 return DescribeLayerResponse(content='')
182 res = tpl.render(gws.TemplateRenderInput(args={'layer': layer}, locale=gws.lib.intl.locale(p.localeUid, project.localeUids), user=req.user))
184 return DescribeLayerResponse(content=res.content)
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."""
190 propses = self._get_features(req, p)
191 return GetFeaturesResponse(features=propses)
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
198 propses = self._get_features(req, p)
199 js = gws.lib.jsonx.to_string({'features': propses})
201 return gws.ContentResponse(mimeType=gws.lib.mime.JSON, content=js)
203 ##
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')
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 )
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)
228 if p.compositeLayerUids:
229 lri.extraParams['compositeLayerUids'] = p.compositeLayerUids
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 )
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()
246 return self._empty_pixel
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
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()
271 if not lro:
272 return self._empty_pixel
274 content = lro.content
276 # for public tiled layers, write tiles to the web cache
277 # so they will be subsequently served directly by nginx
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)
286 return gws.lib.mime.PNG, content
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
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)
302 crs = gws.lib.crs.get(p.crs) or layer.mapCrs
304 bounds = layer.bounds
305 if p.bbox:
306 bounds = gws.lib.bounds.from_extent(p.bbox, crs)
308 search = gws.SearchQuery(
309 bounds=bounds,
310 project=project,
311 layers=[layer],
312 limit=_GET_FEATURES_LIMIT,
313 )
315 features = layer.find_features(search, req.user)
316 if not features:
317 return []
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)
324 mc = gws.ModelContext(
325 op=gws.ModelOperation.read,
326 target=gws.ModelReadTarget.map,
327 user=req.user,
328 )
330 return [f.model.feature_to_view_props(f, mc) for f in features]