Coverage for gws-app/gws/gis/render/__init__.py: 17%

156 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-17 01:37 +0200

1"""Map render utilities""" 

2 

3import math 

4 

5import gws 

6import gws.gis.extent 

7import gws.lib.image 

8import gws.lib.svg 

9import gws.lib.uom 

10import gws.lib.xmlx as xmlx 

11 

12MAX_DPI = 1200 

13MIN_DPI = gws.lib.uom.PDF_DPI 

14 

15 

16# Map Views 

17 

18def map_view_from_center( 

19 size: gws.UomSize, 

20 center: gws.Point, 

21 crs: gws.Crs, 

22 dpi, 

23 scale, 

24 rotation=0, 

25) -> gws.MapView: 

26 return _map_view(None, center, crs, dpi, rotation, scale, size) 

27 

28 

29def map_view_from_bbox( 

30 size: gws.UomSize, 

31 bbox: gws.Extent, 

32 crs: gws.Crs, 

33 dpi, 

34 rotation=0, 

35) -> gws.MapView: 

36 return _map_view(bbox, None, crs, dpi, rotation, None, size) 

37 

38 

39def _map_view(bbox, center, crs, dpi, rotation, scale, size): 

40 view = gws.MapView( 

41 dpi=dpi, 

42 rotation=rotation, 

43 ) 

44 

45 w, h, u = size 

46 if u == gws.Uom.mm: 

47 view.mmSize = w, h 

48 view.pxSize = gws.lib.uom.size_mm_to_px(view.mmSize, view.dpi) 

49 if u == gws.Uom.px: 

50 view.pxSize = w, h 

51 view.mmSize = gws.lib.uom.size_px_to_mm(view.pxSize, view.dpi) 

52 

53 if bbox: 

54 view.bounds = gws.Bounds(crs=crs, extent=bbox) 

55 view.center = gws.gis.extent.center(bbox) 

56 bw, bh = gws.gis.extent.size(bbox) 

57 view.scale = gws.lib.uom.res_to_scale(bw / view.pxSize[0]) 

58 return view 

59 

60 if center: 

61 view.center = center 

62 view.scale = scale 

63 

64 # @TODO assuming projection units are 'm' 

65 projection_units_per_mm = scale / 1000.0 

66 size = view.mmSize[0] * projection_units_per_mm, view.mmSize[1] * projection_units_per_mm 

67 bbox = gws.gis.extent.from_center(center, size) 

68 view.bounds = gws.Bounds(crs=crs, extent=bbox) 

69 return view 

70 

71 raise gws.Error('center or bbox required') 

72 

73 

74def map_view_transformer(view: gws.MapView): 

75 """Create a pixel transformer f(map_x, map_y) -> (pixel_x, pixel_y) for a view""" 

76 

77 # @TODO cache the transformer 

78 

79 def translate(x, y): 

80 x = x - ext[0] 

81 y = ext[3] - y 

82 return x * m2px, y * m2px 

83 

84 def translate_int(x, y): 

85 x, y = translate(x, y) 

86 return int(x), int(y) 

87 

88 def rotate(x, y): 

89 return ( 

90 cosa * (x - ox) - sina * (y - oy) + ox, 

91 sina * (x - ox) + cosa * (y - oy) + oy) 

92 

93 def translate_rotate_int(x, y): 

94 x, y = translate(x, y) 

95 x, y = rotate(x, y) 

96 return int(x), int(y) 

97 

98 m2px = 1000.0 * gws.lib.uom.mm_to_px(1 / view.scale, view.dpi) 

99 

100 ext = view.bounds.extent 

101 

102 if not view.rotation: 

103 return translate_int 

104 

105 ox, oy = translate(*gws.gis.extent.center(ext)) 

106 cosa = math.cos(math.radians(view.rotation)) 

107 sina = math.sin(math.radians(view.rotation)) 

108 

109 return translate_rotate_int 

110 

111 

112# Rendering 

113 

114 

115class _Renderer(gws.Data): 

116 mri: gws.MapRenderInput 

117 mro: gws.MapRenderOutput 

118 rasterView: gws.MapView 

119 vectorView: gws.MapView 

120 imgCount: int 

121 svgCount: int 

122 

123 

124def render_map(mri: gws.MapRenderInput) -> gws.MapRenderOutput: 

125 rd = _Renderer( 

126 mri=mri, 

127 mro=gws.MapRenderOutput(planes=[]), 

128 imgCount=0, 

129 svgCount=0 

130 ) 

131 

132 # vectors always use PDF_DPI 

133 rd.vectorView = _map_view(mri.bbox, mri.center, mri.crs, gws.lib.uom.PDF_DPI, mri.rotation, mri.scale, mri.mapSize) 

134 

135 if mri.mapSize[2] == gws.Uom.px: 

136 # if they want pixels, use PDF_PDI for rasters as well 

137 rd.rasterView = rd.vectorView 

138 

139 elif mri.mapSize[2] == gws.Uom.mm: 

140 # if they want mm, rasters should use they own dpi 

141 raster_dpi = min(MAX_DPI, max(MIN_DPI, rd.mri.dpi)) 

142 rd.rasterView = _map_view(mri.bbox, mri.center, mri.crs, raster_dpi, mri.rotation, mri.scale, mri.mapSize) 

143 

144 else: 

145 raise gws.Error(f'invalid size {mri.mapSize!r}') 

146 

147 # NB: planes are top-to-bottom 

148 

149 for n, p in enumerate(reversed(mri.planes)): 

150 if mri.notify: 

151 mri.notify('begin_plane', p) 

152 try: 

153 _render_plane(rd, p) 

154 except Exception: 

155 gws.log.exception(f'RENDER_FAILED: plane {len(mri.planes) - n - 1}') 

156 if mri.notify: 

157 mri.notify('end_plane', p) 

158 

159 rd.mro.view = rd.vectorView 

160 return rd.mro 

161 

162 

163def _render_plane(rd: _Renderer, plane: gws.MapRenderInputPlane): 

164 s = plane.opacity 

165 if s is not None: 

166 opacity = s 

167 elif plane.layer: 

168 opacity = plane.layer.opacity 

169 else: 

170 opacity = 1 

171 

172 if plane.type == gws.MapRenderInputPlaneType.imageLayer: 

173 extra_params = {} 

174 if plane.subLayers: 

175 extra_params = {'layers': plane.subLayers} 

176 lro = plane.layer.render(gws.LayerRenderInput( 

177 type=gws.LayerRenderInputType.box, 

178 view=rd.rasterView, 

179 extraParams=extra_params, 

180 user=rd.mri.user, 

181 )) 

182 if lro: 

183 _add_image(rd, gws.lib.image.from_bytes(lro.content), opacity) 

184 return 

185 

186 if plane.type == gws.MapRenderInputPlaneType.image: 

187 _add_image(rd, plane.image, opacity) 

188 return 

189 

190 if plane.type == gws.MapRenderInputPlaneType.svgLayer: 

191 lro = plane.layer.render(gws.LayerRenderInput( 

192 type=gws.LayerRenderInputType.svg, 

193 view=rd.vectorView, 

194 style=plane.styles[0] if plane.styles else None, 

195 user=rd.mri.user, 

196 )) 

197 if lro: 

198 _add_svg_elements(rd, lro.tags, opacity) 

199 return 

200 

201 if plane.type == gws.MapRenderInputPlaneType.features: 

202 style_dct = {} 

203 if plane.styles: 

204 style_dct = {s.cssSelector: s for s in plane.styles} 

205 for f in plane.features: 

206 tags = f.to_svg(rd.vectorView, f.views.get('label', ''), style_dct.get(f.cssSelector)) 

207 _add_svg_elements(rd, tags, opacity) 

208 return 

209 

210 if plane.type == gws.MapRenderInputPlaneType.svgSoup: 

211 els = gws.lib.svg.soup_to_fragment(rd.vectorView, plane.soupPoints, plane.soupTags) 

212 _add_svg_elements(rd, els, opacity) 

213 return 

214 

215 

216def _add_image(rd: _Renderer, img, opacity): 

217 last_type = rd.mro.planes[-1].type if rd.mro.planes else None 

218 

219 if last_type != gws.MapRenderOutputPlaneType.image: 

220 # NB use background for the first composition only 

221 background = rd.mri.backgroundColor if rd.imgCount == 0 else None 

222 rd.mro.planes.append(gws.MapRenderOutputPlane( 

223 type=gws.MapRenderOutputPlaneType.image, 

224 image=gws.lib.image.from_size(rd.rasterView.pxSize, background))) 

225 

226 rd.mro.planes[-1].image = rd.mro.planes[-1].image.compose(img, opacity) 

227 rd.imgCount += 1 

228 

229 

230def _add_svg_elements(rd: _Renderer, elements, opacity): 

231 # @TODO opacity for svgs 

232 

233 last_type = rd.mro.planes[-1].type if rd.mro.planes else None 

234 

235 if last_type != gws.MapRenderOutputPlaneType.svg: 

236 rd.mro.planes.append(gws.MapRenderOutputPlane( 

237 type=gws.MapRenderOutputPlaneType.svg, 

238 elements=[])) 

239 

240 rd.mro.planes[-1].elements.extend(elements) 

241 rd.svgCount += 1 

242 

243 

244# Output 

245 

246 

247def output_to_html_element(mro: gws.MapRenderOutput, wrap='relative') -> gws.XmlElement: 

248 w, h = mro.view.mmSize 

249 

250 css_size = f'left:0;top:0;width:{int(w)}mm;height:{int(h)}mm' 

251 css_abs = f'position:absolute;{css_size}' 

252 

253 tags: list[gws.XmlElement] = [] 

254 

255 for plane in mro.planes: 

256 if plane.type == gws.MapRenderOutputPlaneType.image: 

257 img_path = plane.image.to_path(gws.u.printtemp('mro.png')) 

258 tags.append(xmlx.tag('img', {'style': css_abs, 'src': img_path})) 

259 if plane.type == gws.MapRenderOutputPlaneType.path: 

260 tags.append(xmlx.tag('img', {'style': css_abs, 'src': plane.path})) 

261 if plane.type == gws.MapRenderOutputPlaneType.svg: 

262 tags.append(gws.lib.svg.fragment_to_element(plane.elements, {'style': css_abs})) 

263 

264 if not tags: 

265 tags.append(xmlx.tag('img')) 

266 

267 css_div = None 

268 if wrap and wrap in {'relative', 'absolute', 'fixed'}: 

269 css_div = f'position:{wrap};overflow:hidden;{css_size}' 

270 return xmlx.tag('div', {'style': css_div}, *tags) 

271 

272 

273def output_to_html_string(mro: gws.MapRenderOutput, wrap='relative') -> str: 

274 div = output_to_html_element(mro, wrap) 

275 return div.to_string()