Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions doc/examples/notebooks/jupyter_tutorial.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -221,11 +221,12 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"**Only when using ipyleaflet**, the map display includes a button for querying data ( ℹ ), a button for displaying and editing computational region (□), and a button for drawing simple vector geometries (🖊) that can be saved as a GRASS native vector map.\n",
"**Only when using ipyleaflet**, the map display includes a button for querying data ( ℹ ), a button for displaying and editing computational region (□), a button for drawing simple vector geometries (🖊) that can be saved as a GRASS native vector map, and a button for plotting raster values along a line (📈).\n",
"\n",
"1. Query raster/vector layers: Click the **info** button ( ℹ ) to enable query mode, then select a point on the map to retrieve information. Toggle the button off when finished.\n",
"2. View and edit the computational region: Click the **computational region** button (□) to display and optionally adjust the region. Update it by clicking **Update region**, then toggle the button off when done.\n",
"3. Draw and save geometries: Click the **pencil** button (🖊) to draw shapes on the map, name the vector map, and save it. The geometry will be added as a new layer and the drawing tool will close automatically.\n"
"3. Draw and save geometries: Click the **pencil** button (🖊) to draw shapes on the map, name the vector map, and save it. The geometry will be added as a new layer and the drawing tool will close automatically.\n",
"4. Profile rasters along a line: Click the **line chart** button (📈), select the rasters, and draw a line. The profile is plotted below the map and is available as the `profile` attribute of the map, e.g., `raleigh_map.profile.data` or `raleigh_map.profile.save(\"profile.png\")`.\n"
]
},
{
Expand Down Expand Up @@ -259,6 +260,25 @@
"raleigh_map.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"A profile can also be computed without drawing, by giving the line coordinates in the project's coordinate reference system. The figure shows the line with numbered vertices on the raster and the values along the line."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"profile = gj.Profile(\n",
" \"elevation\", [(635000, 222000), (640000, 222000), (640000, 225000)]\n",
")\n",
"profile.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand Down Expand Up @@ -557,7 +577,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.12"
"version": "3.12.14"
}
},
"nbformat": 4,
Expand Down
1 change: 1 addition & 0 deletions python/grass/jupyter/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ MODULES = \
setup \
map \
interactivemap \
profile \
region \
map3d \
seriesmap \
Expand Down
11 changes: 11 additions & 0 deletions python/grass/jupyter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@
>>> streams_map.d_vect(map="streams")
>>> streams_map.show()

Values of rasters along a line can be sampled and plotted with the line
vertices marked on a map and on the chart::

>>> profile = gj.Profile("elevation", [(638000, 220000), (640000, 222000)])
>>> profile.save("profile.png")

The interactive map has a tool for drawing the line; the profile of the selected
rasters is then shown below the map and available as the *profile* attribute.

Other classes and functions are described below and in the example notebooks.
Static HTML versions of the example notebooks are available on GitHub_
and interactive ones with live code are available on Binder:
Expand Down Expand Up @@ -107,6 +116,7 @@
from .interactivemap import InteractiveMap, Raster, Vector
from .map import Map
from .map3d import Map3D
from .profile import Profile
from .seriesmap import SeriesMap
from .setup import init
from .timeseriesmap import TimeSeriesMap
Expand All @@ -115,6 +125,7 @@
"InteractiveMap",
"Map",
"Map3D",
"Profile",
"Raster",
"SeriesMap",
"TimeSeriesMap",
Expand Down
140 changes: 136 additions & 4 deletions python/grass/jupyter/interactivemap.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import base64
import json
from pathlib import Path
from .profile import Profile
from .reprojection_renderer import ReprojectionRenderer

from .utils import (
Expand All @@ -28,6 +29,7 @@
query_raster,
query_vector,
reproject_latlon,
reproject_latlon_coords,
)


Expand Down Expand Up @@ -302,6 +304,8 @@ def _import_ipyleaflet(error):
self.width = int(width)
self.height = int(height)
self._controllers = {}
self._profile_controller = None
self._bottom_output = None

# Store vector and raster name
self.raster_name = []
Expand All @@ -318,6 +322,8 @@ def _import_ipyleaflet(error):
self.map = self._ipyleaflet.Map(
basemap=basemap, layout=layout, scroll_wheel_zoom=True
)
# Output shown below the map, used by the profile tool for its figure.
self._bottom_output = self._ipywidgets.Output()

else:
self.map = self._folium.Map(
Expand Down Expand Up @@ -419,6 +425,28 @@ def setup_query_interface(self):
controller_class=InteractiveQueryController,
)

def setup_profile_interface(self):
"""Sets up the raster profile interface.

This includes creating a toggle button to activate the profile mode,
and instantiating an InteractiveProfileController which lets the user
draw a line and plots the values of the selected rasters along it.
"""
button = self._create_toggle_button(
icon="line-chart",
tooltip=_("Click to draw a line and plot raster values along it"),
controller_class=InteractiveProfileController,
)
self._profile_controller = self._controllers[button]
return button

@property
def profile(self):
"""Profile of the last line drawn with the profile tool, None if no line"""
if self._profile_controller is None:
return None
return self._profile_controller.profile

def _create_toggle_button(self, icon, tooltip, controller_class):
button = self._ipywidgets.ToggleButton(
icon=icon,
Expand All @@ -437,6 +465,7 @@ def _create_toggle_button(self, icon, tooltip, controller_class):
rasters=self.raster_name,
vectors=self.vector_name,
width=self.width,
output_widget=self._bottom_output,
)
self._controllers[button] = controller
button.observe(self._toggle_mode, names="value")
Expand All @@ -453,8 +482,9 @@ def _toggle_mode(self, change):
self._controllers[change["owner"]].deactivate()

def show(self):
"""This function returns a folium figure or ipyleaflet map object
with a GRASS raster and/or vector overlaid on a basemap.
"""This function returns a folium figure or an ipywidgets box
with the ipyleaflet map and an output area below it, with a GRASS
raster and/or vector overlaid on a basemap.

If map has layer control enabled, additional layers cannot be
added after calling show()."""
Expand All @@ -463,9 +493,10 @@ def show(self):
self.setup_query_interface(),
self.setup_computational_region_interface(),
self.setup_drawing_interface(),
self.setup_profile_interface(),
]
button_box = self._ipywidgets.HBox(
toggle_buttons, layout=self._ipywidgets.Layout(width="150px")
toggle_buttons, layout=self._ipywidgets.Layout(width="200px")
)
self.map.add(
self._ipyleaflet.WidgetControl(widget=button_box, position="topright")
Expand All @@ -486,7 +517,7 @@ def show(self):

# ipyleaflet
self.map.add(self.layer_control_object)
return self.map
return self._ipywidgets.VBox([self.map, self._bottom_output])

def save(self, filename):
"""Save map as an html map.
Expand Down Expand Up @@ -846,3 +877,104 @@ def clear_popups(self):
for item in reversed(list(self.map.layers)):
if isinstance(item, self._ipyleaflet.Popup):
self.map.remove(item)


class InteractiveProfileController:
"""A controller for drawing a line and plotting raster values along it.

Attributes:
map: The ipyleaflet.Map object.
_ipyleaflet: The ipyleaflet module.
_ipywidgets: The ipywidgets module.
raster_name: List of raster layers on the map.
output_widget: The ipywidgets.Output where the profile figure is shown.
draw_control: The draw control restricted to lines.
raster_select: The widget for selecting the rasters to profile.
select_control: The widget control holding raster_select.
profile: The Profile of the last drawn line, None if no line.
"""

def __init__(
self, map_object, ipyleaflet, ipywidgets, rasters, output_widget, **kwargs
): # pylint: disable=unused-argument
"""Initializes the InteractiveProfileController.

:param map_object: The map object.
:param ipyleaflet: The ipyleaflet module.
:param ipywidgets: The ipywidgets module.
:param rasters: List of raster layers.
:param output_widget: The ipywidgets.Output where the profile is shown.
"""
self.map = map_object
self._ipyleaflet = ipyleaflet
self._ipywidgets = ipywidgets
self.raster_name = rasters
self.output_widget = output_widget
self.profile = None
# An empty dict disables a shape; only a line makes sense for a profile.
self.draw_control = self._ipyleaflet.DrawControl(
polygon={},
circlemarker={},
polyline={"shapeOptions": {"color": "#d62728", "weight": 3}},
edit=False,
remove=False,
)
# Registered once, so toggling the tool does not stack callbacks.
self.draw_control.on_draw(self._handle_draw)
self.raster_select = self._ipywidgets.SelectMultiple(
description=_("Rasters:"),
rows=4,
style={"description_width": "initial"},
)
self.select_control = None

def activate(self):
"""Activates line drawing and shows the raster selection."""
self.raster_select.options = list(self.raster_name)
self.raster_select.value = tuple(self.raster_name)
self.map.add(self.draw_control)
self.select_control = self._ipyleaflet.WidgetControl(
widget=self.raster_select, position="topright"
)
self.map.add(self.select_control)

def deactivate(self):
"""Removes the drawn lines, the draw control, and the raster selection."""
self.draw_control.clear()
if self.draw_control in self.map.controls:
self.map.remove(self.draw_control)
if self.select_control in self.map.controls:
self.map.remove(self.select_control)
self.select_control = None

def _handle_draw(self, _control, action, geo_json):
"""Computes and shows the profile of a newly drawn line.

:param str action: The action type.
:param dict geo_json: The GeoJSON data.
"""
if action != "created" or geo_json["geometry"]["type"] != "LineString":
return
# Inside the output context, so errors show up in the notebook.
with self.output_widget:
self.output_widget.clear_output(wait=True)
rasters = list(self.raster_select.value)
if not rasters:
print(_("Select at least one raster to profile."))
return
self.profile = self.create_profile(
geo_json["geometry"]["coordinates"], rasters
)
self.profile.show()

def create_profile(self, lonlat_coordinates, rasters):
"""Creates the profile of rasters along a line.

:param lonlat_coordinates: GeoJSON vertices as [longitude, latitude] pairs
:param rasters: List of raster names.
:return: Profile object
"""
points = reproject_latlon_coords(
[(lat, lon) for lon, lat in lonlat_coordinates]
)
return Profile(rasters, [(east, north) for east, north, _elevation in points])
Loading
Loading