Coverage for gws-app/gws/gis/render/__init__.py: 71%
156 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
1"""Map render utilities."""
3import math
5import gws
6import gws.lib.extent
7import gws.lib.image
8import gws.lib.svg
9import gws.lib.uom
10import gws.lib.xmlx as xmlx
12MAX_DPI = 1200
13MIN_DPI = gws.lib.uom.PDF_DPI
16# Map Views
18def map_view_from_center(
19 size: gws.UomSize,
20 center: gws.Point,
21 crs: gws.Crs,
22 dpi: int,
23 scale: float,
24 rotation: float = 0,
25) -> gws.MapView:
26 """Creates a map view based on a center point.
28 Args:
29 size: The map size in units.
30 center: The center point of the map.
31 crs: The coordinate reference system.
32 dpi: The resolution in dots per inch.
33 scale: The map scale.
34 rotation: The map rotation angle in degrees.
36 Returns:
37 A configured MapView instance.
38 """
39 return _map_view(None, center, crs, dpi, rotation, scale, size)
42def map_view_from_bbox(
43 size: gws.UomSize,
44 bbox: gws.Extent,
45 crs: gws.Crs,
46 dpi: int,
47 rotation: float = 0,
48) -> gws.MapView:
49 """Creates a map view based on a bounding box.
51 Args:
52 size: The map size in units.
53 bbox: The bounding box of the map.
54 crs: The coordinate reference system.
55 dpi: The resolution in dots per inch.
56 rotation: The map rotation angle in degrees.
58 Returns:
59 A configured MapView instance.
60 """
61 return _map_view(bbox, None, crs, dpi, rotation, None, size)
63def _map_view(
64 bbox: gws.Extent | None,
65 center: gws.Point | None,
66 crs: gws.Crs,
67 dpi: int,
68 rotation: float,
69 scale: float | None,
70 size: gws.UomSize
71) -> gws.MapView:
72 """Creates a generic map view from either a bounding box or a center point.
74 Args:
75 bbox: The bounding box for the map view.
76 center: The center point for the map view.
77 crs: The coordinate reference system.
78 dpi: The resolution in dots per inch.
79 rotation: The rotation angle in degrees.
80 scale: The map scale (if using center-based view).
81 size: The size of the map.
83 Returns:
84 A MapView instance with computed properties.
86 Raises:
87 gws.Error: If neither center nor bbox is provided.
88 """
89 view = gws.MapView(
90 dpi=dpi,
91 rotation=rotation,
92 )
94 w, h, u = size
95 if u == gws.Uom.mm:
96 view.mmSize = w, h
97 view.pxSize = gws.lib.uom.size_mm_to_px(view.mmSize, view.dpi)
98 if u == gws.Uom.px:
99 view.pxSize = w, h
100 view.mmSize = gws.lib.uom.size_px_to_mm(view.pxSize, view.dpi)
102 if bbox:
103 view.bounds = gws.Bounds(crs=crs, extent=bbox)
104 view.center = gws.lib.extent.center(bbox)
105 bw, bh = gws.lib.extent.size(bbox)
106 view.scale = gws.lib.uom.res_to_scale(bw / view.pxSize[0])
107 return view
109 if center:
110 view.center = center
111 view.scale = scale
113 # @TODO assuming projection units are 'm'
114 projection_units_per_mm = scale / 1000.0
115 size = view.mmSize[0] * projection_units_per_mm, view.mmSize[1] * projection_units_per_mm
116 bbox = gws.lib.extent.from_center(center, size)
117 view.bounds = gws.Bounds(crs=crs, extent=bbox)
118 return view
120 raise gws.Error('center or bbox required')
123def map_view_transformer(view: gws.MapView):
124 """Creates a pixel transformer f(map_x, map_y) -> (pixel_x, pixel_y) for a view
126 Args:
127 view: The map view instance.
129 Returns:
130 A function that transforms map coordinates (x, y) into pixel coordinates.
131 """
133 # @TODO cache the transformer
135 def translate(x, y):
136 x = x - ext[0]
137 y = ext[3] - y
138 return x * m2px, y * m2px
140 def translate_int(x, y):
141 x, y = translate(x, y)
142 return int(x), int(y)
144 def rotate(x, y):
145 return (
146 cosa * (x - ox) - sina * (y - oy) + ox,
147 sina * (x - ox) + cosa * (y - oy) + oy)
149 def translate_rotate_int(x, y):
150 x, y = translate(x, y)
151 x, y = rotate(x, y)
152 return int(x), int(y)
154 m2px = 1000.0 * gws.lib.uom.mm_to_px(1 / view.scale, view.dpi)
156 ext = view.bounds.extent
158 if not view.rotation:
159 return translate_int
161 ox, oy = translate(*gws.lib.extent.center(ext))
162 cosa = math.cos(math.radians(view.rotation))
163 sina = math.sin(math.radians(view.rotation))
165 return translate_rotate_int
168# Rendering
171class _Renderer(gws.Data):
172 mri: gws.MapRenderInput
173 mro: gws.MapRenderOutput
174 rasterView: gws.MapView
175 vectorView: gws.MapView
176 imgCount: int
177 svgCount: int
180def render_map(mri: gws.MapRenderInput) -> gws.MapRenderOutput:
181 """Renders a map based on input parameters.
183 Args:
184 mri: The map render input configuration.
186 Returns:
187 A MapRenderOutput instance containing rendered data.
188 """
189 rd = _Renderer(
190 mri=mri,
191 mro=gws.MapRenderOutput(planes=[]),
192 imgCount=0,
193 svgCount=0
194 )
196 # vectors always use PDF_DPI
197 rd.vectorView = _map_view(mri.bbox, mri.center, mri.crs, gws.lib.uom.PDF_DPI, mri.rotation, mri.scale, mri.mapSize)
199 if mri.mapSize[2] == gws.Uom.px:
200 # if they want pixels, use PDF_PDI for rasters as well
201 rd.rasterView = rd.vectorView
203 elif mri.mapSize[2] == gws.Uom.mm:
204 # if they want mm, rasters should use they own dpi
205 raster_dpi = min(MAX_DPI, max(MIN_DPI, rd.mri.dpi))
206 rd.rasterView = _map_view(mri.bbox, mri.center, mri.crs, raster_dpi, mri.rotation, mri.scale, mri.mapSize)
208 else:
209 raise gws.Error(f'invalid size {mri.mapSize!r}')
211 # NB: planes are top-to-bottom
213 for n, p in enumerate(reversed(mri.planes)):
214 if mri.notify:
215 mri.notify('begin_plane', p)
216 try:
217 _render_plane(rd, p)
218 except Exception:
219 gws.log.exception(f'RENDER_FAILED: plane {len(mri.planes) - n - 1}')
220 if mri.notify:
221 mri.notify('end_plane', p)
223 rd.mro.view = rd.vectorView
224 return rd.mro
227def _render_plane(rd: _Renderer, plane: gws.MapRenderInputPlane):
228 s = plane.opacity
229 if s is not None:
230 opacity = s
231 elif plane.layer:
232 opacity = plane.layer.opacity
233 else:
234 opacity = 1
236 if plane.type == gws.MapRenderInputPlaneType.imageLayer:
237 extra_params = {}
238 if plane.compositeLayerUids:
239 extra_params = {'compositeLayerUids': plane.compositeLayerUids}
240 lro = plane.layer.render(gws.LayerRenderInput(
241 type=gws.LayerRenderInputType.box,
242 view=rd.rasterView,
243 extraParams=extra_params,
244 user=rd.mri.user,
245 ))
246 if lro:
247 _add_image(rd, gws.lib.image.from_bytes(lro.content), opacity)
248 return
250 if plane.type == gws.MapRenderInputPlaneType.image:
251 _add_image(rd, plane.image, opacity)
252 return
254 if plane.type == gws.MapRenderInputPlaneType.svgLayer:
255 lro = plane.layer.render(gws.LayerRenderInput(
256 type=gws.LayerRenderInputType.svg,
257 view=rd.vectorView,
258 style=plane.styles[0] if plane.styles else None,
259 user=rd.mri.user,
260 ))
261 if lro:
262 _add_svg_elements(rd, lro.tags, opacity)
263 return
265 if plane.type == gws.MapRenderInputPlaneType.features:
266 style_dct = {}
267 if plane.styles:
268 style_dct = {s.cssSelector: s for s in plane.styles}
269 for f in plane.features:
270 tags = f.to_svg(rd.vectorView, f.views.get('label', ''), style_dct.get(f.cssSelector))
271 _add_svg_elements(rd, tags, opacity)
272 return
274 if plane.type == gws.MapRenderInputPlaneType.svgSoup:
275 els = gws.lib.svg.soup_to_fragment(rd.vectorView, plane.soupPoints, plane.soupTags)
276 _add_svg_elements(rd, els, opacity)
277 return
280def _add_image(rd: _Renderer, img, opacity):
281 last_type = rd.mro.planes[-1].type if rd.mro.planes else None
283 if last_type != gws.MapRenderOutputPlaneType.image:
284 # NB use background for the first composition only
285 background = rd.mri.backgroundColor if rd.imgCount == 0 else None
286 rd.mro.planes.append(gws.MapRenderOutputPlane(
287 type=gws.MapRenderOutputPlaneType.image,
288 image=gws.lib.image.from_size(rd.rasterView.pxSize, background)))
290 rd.mro.planes[-1].image = rd.mro.planes[-1].image.compose(img, opacity)
291 rd.imgCount += 1
294def _add_svg_elements(rd: _Renderer, elements, opacity):
295 # @TODO opacity for svgs
297 last_type = rd.mro.planes[-1].type if rd.mro.planes else None
299 if last_type != gws.MapRenderOutputPlaneType.svg:
300 rd.mro.planes.append(gws.MapRenderOutputPlane(
301 type=gws.MapRenderOutputPlaneType.svg,
302 elements=[]))
304 rd.mro.planes[-1].elements.extend(elements)
305 rd.svgCount += 1
308# Output
311def output_to_html_element(mro: gws.MapRenderOutput, wrap='relative') -> gws.XmlElement:
312 """Converts a MapRenderOutput to an HTML element.
314 Args:
315 mro: The MapRenderOutput object to convert.
316 wrap: The CSS position value for the wrapper div. Must be one of
317 'relative', 'absolute', 'fixed', or None. Default is 'relative'.
319 Returns:
320 A gws.XmlElement representing a div containing the map output.
321 """
322 w, h = mro.view.mmSize
324 css_size = f'left:0;top:0;width:{int(w)}mm;height:{int(h)}mm'
325 css_abs = f'position:absolute;{css_size}'
327 tags: list[gws.XmlElement] = []
329 for plane in mro.planes:
330 if plane.type == gws.MapRenderOutputPlaneType.image:
331 img_path = plane.image.to_path(gws.u.ephemeral_path('mro.png'))
332 tags.append(xmlx.tag('img', {'style': css_abs, 'src': img_path}))
333 if plane.type == gws.MapRenderOutputPlaneType.path:
334 tags.append(xmlx.tag('img', {'style': css_abs, 'src': plane.path}))
335 if plane.type == gws.MapRenderOutputPlaneType.svg:
336 tags.append(gws.lib.svg.fragment_to_element(plane.elements, {'style': css_abs}))
338 if not tags:
339 tags.append(xmlx.tag('img'))
341 css_div = None
342 if wrap and wrap in {'relative', 'absolute', 'fixed'}:
343 css_div = f'position:{wrap};overflow:hidden;{css_size}'
344 return xmlx.tag('div', {'style': css_div}, *tags)
347def output_to_html_string(mro: gws.MapRenderOutput, wrap='relative') -> str:
348 """Converts a MapRenderOutput to an HTML string.
350 Args:
351 mro: The MapRenderOutput object to convert.
352 wrap: The CSS position value for the wrapper div. Must be one of
353 'relative', 'absolute', 'fixed', or None. Default is 'relative'.
355 Returns:
356 A string containing the HTML representation of the map output.
357 """
358 div = output_to_html_element(mro, wrap)
359 return div.to_string()