interact_display.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. #
  2. # AUTHOR(S): Caitlin Haedrich <caitlin DOT haedrich AT gmail>
  3. #
  4. # PURPOSE: This module contains functions for interactive display
  5. # in Jupyter Notebooks.
  6. #
  7. # COPYRIGHT: (C) 2021 Caitlin Haedrich, and by the GRASS Development Team
  8. #
  9. # This program is free software under the GNU General Public
  10. # License (>=v2). Read the file COPYING that comes with GRASS
  11. # for details.
  12. import os
  13. import sys
  14. import tempfile
  15. import weakref
  16. from pathlib import Path
  17. import grass.script as gs
  18. from .display import GrassRenderer
  19. from .region import RegionManagerForInteractiveMap
  20. from .utils import (
  21. estimate_resolution,
  22. get_location_proj_string,
  23. get_region,
  24. reproject_region,
  25. setup_location,
  26. )
  27. class InteractiveMap:
  28. """This class creates interative GRASS maps with folium.
  29. Basic Usage:
  30. >>> m = InteractiveMap()
  31. >>> m.add_vector("streams")
  32. >>> m.add_raster("elevation")
  33. >>> m.add_layer_control()
  34. >>> m.show()
  35. """
  36. def __init__(self, width=400, height=400, use_region=False, saved_region=None):
  37. """Creates a blank folium map centered on g.region.
  38. :param int height: height in pixels of figure (default 400)
  39. :param int width: width in pixels of figure (default 400)
  40. """
  41. import folium
  42. self._folium = folium
  43. # Store height and width
  44. self.width = width
  45. self.height = height
  46. # Make temporary folder for all our files
  47. self._tmp_dir = tempfile.TemporaryDirectory()
  48. # Remember original environment; all environments used
  49. # in this class are derived from this one
  50. self._src_env = os.environ.copy()
  51. # Set up temporary locations in WGS84 and Pseudo-Mercator
  52. # We need two because folium uses WGS84 for vectors and coordinates
  53. # and Pseudo-Mercator for raster overlays
  54. self.rcfile_psmerc, self._psmerc_env = setup_location(
  55. "psmerc", self._tmp_dir.name, "3857", self._src_env
  56. )
  57. self.rcfile_wgs84, self._wgs84_env = setup_location(
  58. "wgs84", self._tmp_dir.name, "4326", self._src_env
  59. )
  60. # Get Center of temporary GRASS regions
  61. center = gs.parse_command("g.region", flags="cg", env=self._wgs84_env)
  62. center = (float(center["center_northing"]), float(center["center_easting"]))
  63. # Create Folium Map
  64. self.map = self._folium.Map(
  65. width=self.width,
  66. height=self.height,
  67. location=center,
  68. tiles="cartodbpositron",
  69. )
  70. # Set LayerControl default
  71. self.layer_control = False
  72. # region handling
  73. self._region_manager = RegionManagerForInteractiveMap(
  74. use_region, saved_region, self._src_env, self._psmerc_env
  75. )
  76. # Cleanup rcfiles with finalizer
  77. def remove_if_exists(path):
  78. if sys.version_info < (3, 8):
  79. try:
  80. os.remove(path)
  81. except FileNotFoundError:
  82. pass
  83. else:
  84. path.unlink(missing_ok=True)
  85. def clean_up(paths):
  86. for path in paths:
  87. remove_if_exists(path)
  88. self._finalizer = weakref.finalize(
  89. self, clean_up, [Path(self.rcfile_psmerc), Path(self.rcfile_wgs84)]
  90. )
  91. def add_vector(self, name):
  92. """Imports vector into temporary WGS84 location,
  93. re-formats to a GeoJSON and adds to folium map.
  94. :param str name: name of vector to be added to map;
  95. positional-only parameter
  96. """
  97. # Find full name of vector
  98. file_info = gs.find_file(name, element="vector")
  99. full_name = file_info["fullname"]
  100. name = file_info["name"]
  101. mapset = file_info["mapset"]
  102. new_name = full_name.replace("@", "_")
  103. # set bbox
  104. self._region_manager.set_bbox_vector(full_name)
  105. # Reproject vector into WGS84 Location
  106. env_info = gs.gisenv(env=self._src_env)
  107. gs.run_command(
  108. "v.proj",
  109. input=name,
  110. output=new_name,
  111. mapset=mapset,
  112. location=env_info["LOCATION_NAME"],
  113. dbase=env_info["GISDBASE"],
  114. env=self._wgs84_env,
  115. )
  116. # Convert to GeoJSON
  117. json_file = Path(self._tmp_dir.name) / f"{new_name}.json"
  118. gs.run_command(
  119. "v.out.ogr",
  120. input=new_name,
  121. output=json_file,
  122. format="GeoJSON",
  123. env=self._wgs84_env,
  124. )
  125. # Import GeoJSON to folium and add to map
  126. self._folium.GeoJson(str(json_file), name=name).add_to(self.map)
  127. def add_raster(self, name, opacity=0.8):
  128. """Imports raster into temporary WGS84 location,
  129. exports as png and overlays on folium map.
  130. Color table for the raster can be modified with `r.colors` before calling
  131. this function.
  132. .. note:: This will only work if the raster is located in the current mapset.
  133. To change the color table of a raster located outside the current mapset,
  134. switch to that mapset with `g.mapset`, modify the color table with `r.color`
  135. then switch back to the initial mapset and run this function.
  136. :param str name: name of raster to add to display; positional-only parameter
  137. :param float opacity: raster opacity, number between
  138. 0 (transparent) and 1 (opaque)
  139. """
  140. # Find full name of raster
  141. file_info = gs.find_file(name, element="cell", env=self._src_env)
  142. full_name = file_info["fullname"]
  143. name = file_info["name"]
  144. mapset = file_info["mapset"]
  145. self._region_manager.set_region_from_raster(full_name)
  146. # Reproject raster into WGS84/epsg3857 location
  147. env_info = gs.gisenv(env=self._src_env)
  148. resolution = estimate_resolution(
  149. raster=name,
  150. mapset=mapset,
  151. location=env_info["LOCATION_NAME"],
  152. dbase=env_info["GISDBASE"],
  153. env=self._psmerc_env,
  154. )
  155. tgt_name = full_name.replace("@", "_")
  156. gs.run_command(
  157. "r.proj",
  158. input=full_name,
  159. output=tgt_name,
  160. mapset=mapset,
  161. location=env_info["LOCATION_NAME"],
  162. dbase=env_info["GISDBASE"],
  163. resolution=resolution,
  164. env=self._psmerc_env,
  165. )
  166. # Write raster to png file with GrassRenderer
  167. region_info = gs.region(env=self._src_env)
  168. png_width = region_info["cols"]
  169. png_height = region_info["rows"]
  170. filename = os.path.join(self._tmp_dir.name, f"{tgt_name}.png")
  171. m = GrassRenderer(
  172. width=png_width,
  173. height=png_height,
  174. env=self._psmerc_env,
  175. filename=filename,
  176. use_region=True,
  177. )
  178. m.run("d.rast", map=tgt_name)
  179. # Reproject bounds of raster for overlaying png
  180. # Bounds need to be in WGS84
  181. old_bounds = get_region(self._src_env)
  182. from_proj = get_location_proj_string(env=self._src_env)
  183. to_proj = get_location_proj_string(env=self._wgs84_env)
  184. bounds = reproject_region(old_bounds, from_proj, to_proj)
  185. new_bounds = [
  186. [bounds["north"], bounds["west"]],
  187. [bounds["south"], bounds["east"]],
  188. ]
  189. # Overlay image on folium map
  190. img = self._folium.raster_layers.ImageOverlay(
  191. image=filename,
  192. name=name,
  193. bounds=new_bounds,
  194. opacity=opacity,
  195. interactive=True,
  196. cross_origin=False,
  197. )
  198. # Add image to map
  199. img.add_to(self.map)
  200. def add_layer_control(self, **kwargs):
  201. """Add layer control to display"
  202. :param `**kwargs`: named arguments to be passed to folium.LayerControl()"""
  203. self.layer_control = True
  204. self.layer_control_object = self._folium.LayerControl(**kwargs)
  205. def show(self):
  206. """This function returns a folium figure object with a GRASS raster
  207. overlayed on a basemap.
  208. If map has layer control enabled, additional layers cannot be
  209. added after calling show()."""
  210. if self.layer_control:
  211. self.map.add_child(self.layer_control_object)
  212. # Create Figure
  213. fig = self._folium.Figure(width=self.width, height=self.height)
  214. # Add map to figure
  215. fig.add_child(self.map)
  216. self.map.fit_bounds(self._region_manager.bbox)
  217. return fig
  218. def save(self, filename):
  219. """Save map as an html map.
  220. :param str filename: name of html file
  221. """
  222. self.map.save(filename)