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
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""Map related commands."""
3from typing import Optional
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
19@gws.ext.config.action('map')
20class Config(gws.base.action.Config):
21 """Serves map images, tiles, legends and features to the client."""
23 pass
26@gws.ext.props.action('map')
27class Props(gws.base.action.Props):
28 pass
31class GetBoxRequest(gws.Request):
32 """Request for a layer image of a bounding box."""
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``."""
50class GetTileRequest(gws.Request):
51 """Request for a layer tile."""
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``."""
65class GetLegendRequest(gws.Request):
66 """Request for a layer legend."""
68 layerUid: str
69 """Layer UID."""
72class ImageResponse(gws.Response):
73 """Image response."""
75 content: bytes
76 """Encoded image."""
77 mimeType: str
78 """MIME type of the image."""
81class DescribeLayerRequest(gws.Request):
82 """Request for a layer description."""
84 layerUid: str
85 """Layer UID."""
88class DescribeLayerResponse(gws.Response):
89 """Layer description response."""
91 content: str
92 """Rendered ``layer.description`` template, or an empty string."""
95class GetFeaturesRequest(gws.Request):
96 """Request for the features of a layer."""
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."""
114class GetFeaturesResponse(gws.Response):
115 """Features response."""
117 features: list[gws.FeatureProps]
118 """Found features."""
121_GET_FEATURES_LIMIT = 10000
122_PIXEL_SIZE_LIMIT = 4096
125@gws.ext.object.action('map')
126class Object(gws.base.action.Object):
127 """Map action.
129 Serves layer images, tiles, legends, descriptions and features to the
130 client.
131 """
133 _empty_pixel = gws.lib.mime.PNG, gws.lib.image.util.empty_pixel()
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)
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)
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)
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)
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)
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)
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)
178 if not tpl:
179 return DescribeLayerResponse(content='')
181 res = tpl.render(gws.TemplateRenderInput(args={'layer': layer}, locale=gws.lib.intl.locale(p.localeUid, project.localeUids), user=req.user))
183 return DescribeLayerResponse(content=res.content)
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."""
189 propses = self._get_features(req, p)
190 return GetFeaturesResponse(features=propses)
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
197 propses = self._get_features(req, p)
198 js = gws.lib.jsonx.to_string({'features': propses})
200 return gws.ContentResponse(mimeType=gws.lib.mime.JSON, content=js)
202 ##
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')
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 )
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)
227 if p.compositeLayerUids:
228 lri.extraParams['compositeLayerUids'] = p.compositeLayerUids
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 )
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()
245 return self._empty_pixel
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
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()
270 if not lro:
271 return self._empty_pixel
273 content = lro.content
275 # for public tiled layers, write tiles to the web cache
276 # so they will be subsequently served directly by nginx
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)
285 return _mime_type(layer), content
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
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)
301 crs = gws.lib.crs.get(p.crs) or layer.mapCrs
303 bounds = layer.bounds
304 if p.bbox:
305 bounds = gws.lib.bounds.from_extent(p.bbox, crs)
307 search = gws.SearchQuery(
308 bounds=bounds,
309 project=project,
310 layers=[layer],
311 limit=_GET_FEATURES_LIMIT,
312 )
314 features = layer.find_features(search, req.user)
315 if not features:
316 return []
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)
323 mc = gws.ModelContext(
324 op=gws.ModelOperation.read,
325 target=gws.ModelReadTarget.map,
326 user=req.user,
327 )
329 return [f.model.feature_to_view_props(f, mc) for f in features]
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