diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index b87bfdca..ae23689f 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 diff --git a/docs/source/modules/sepal_ui.mapping.DrawControl.rst b/docs/source/modules/sepal_ui.mapping.DrawControl.rst new file mode 100644 index 00000000..8bcaf4f3 --- /dev/null +++ b/docs/source/modules/sepal_ui.mapping.DrawControl.rst @@ -0,0 +1,22 @@ +sepal\_ui.mapping.DrawControl +============================= + +.. autoclass:: sepal_ui.mapping.DrawControl + + .. rubric:: Attributes + + .. autosummary:: + + ~DrawControl.m + + .. rubric:: Methods + + .. autosummary:: + :nosignatures: + + ~DrawControl.show + ~DrawControl.hide + +.. automethod:: sepal_ui.mapping.DrawControl.show + +.. automethod:: sepal_ui.mapping.DrawControl.hide \ No newline at end of file diff --git a/docs/source/modules/sepal_ui.mapping.FullScreenControl.rst b/docs/source/modules/sepal_ui.mapping.FullScreenControl.rst index 3239cd6d..3d39be10 100644 --- a/docs/source/modules/sepal_ui.mapping.FullScreenControl.rst +++ b/docs/source/modules/sepal_ui.mapping.FullScreenControl.rst @@ -1,52 +1,23 @@ -sepal\_ui.mapping.SepalMap -========================== +sepal\_ui.mapping.FullScreenControl +=================================== -.. autoclass:: sepal_ui.mapping.SepalMap +.. autoclass:: sepal_ui.mapping.FullScreenControl .. rubric:: Attributes .. autosummary:: - ~SepalMap.ee - ~SepalMap.vinspector - ~SepalMap.loaded_rasters - ~SepalMap.dc + ~FullScreenControl.ICONS + ~FullScreenControl.METHODS + ~FullScreenControl.zoomed + ~FullScreenControl.w_btn + ~FullScreenControl.template .. rubric:: Methods .. autosummary:: :nosignatures: - ~SepalMap.set_drawing_controls - ~SepalMap.remove_last_layer - ~SepalMap.zoom_ee_object - ~SepalMap.zoom_bounds - ~SepalMap.add_raster - ~SepalMap.show_dc - ~SepalMap.hide_dc - ~SepalMap.add_colorbar - ~SepalMap.addLayer - ~SepalMap.get_basemap_list - ~SepalMap.get_viz_params + ~FullScreenControl.toggle_fullscreen -.. automethod:: sepal_ui.mapping.SepalMap.set_drawing_controls - -.. automethod:: sepal_ui.mapping.SepalMap.remove_last_layer - -.. automethod:: sepal_ui.mapping.SepalMap.zoom_ee_object - -.. automethod:: sepal_ui.mapping.SepalMap.zoom_bounds - -.. automethod:: sepal_ui.mapping.SepalMap.add_raster - -.. automethod:: sepal_ui.mapping.SepalMap.show_dc - -.. automethod:: sepal_ui.mapping.SepalMap.hide_dc - -.. automethod:: sepal_ui.mapping.SepalMap.add_colorbar - -.. automethod:: sepal_ui.mapping.SepalMap.addLayer - -.. automethod:: sepal_ui.mapping.SepalMap.get_basemap_list - -.. automethod:: sepal_ui.mapping.SepalMap.get_viz_params +.. automethod:: sepal_ui.mapping.FullScreenControl.toggle_fullscreen diff --git a/docs/source/modules/sepal_ui.mapping.Layer.rst b/docs/source/modules/sepal_ui.mapping.Layer.rst new file mode 100644 index 00000000..dce58337 --- /dev/null +++ b/docs/source/modules/sepal_ui.mapping.Layer.rst @@ -0,0 +1,10 @@ +sepal\_ui.mapping.Layer +======================= + +.. autoclass:: sepal_ui.mapping.Layer + + .. rubric:: Attributes + + .. autosummary:: + + ~Layer.ee_object diff --git a/docs/source/modules/sepal_ui.mapping.MapBtn.rst b/docs/source/modules/sepal_ui.mapping.MapBtn.rst new file mode 100644 index 00000000..d2d40345 --- /dev/null +++ b/docs/source/modules/sepal_ui.mapping.MapBtn.rst @@ -0,0 +1,11 @@ +sepal\_ui.mapping.MapBtn +======================== + +.. autoclass:: sepal_ui.mapping.MapBtn + + .. rubric:: Attributes + + .. autosummary:: + + ~MapBtn.logo + \ No newline at end of file diff --git a/docs/source/modules/sepal_ui.mapping.SepalMap.rst b/docs/source/modules/sepal_ui.mapping.SepalMap.rst index 3d39be10..6e978ee2 100644 --- a/docs/source/modules/sepal_ui.mapping.SepalMap.rst +++ b/docs/source/modules/sepal_ui.mapping.SepalMap.rst @@ -1,23 +1,80 @@ -sepal\_ui.mapping.FullScreenControl -=================================== +sepal\_ui.mapping.SepalMap +========================== -.. autoclass:: sepal_ui.mapping.FullScreenControl +.. autoclass:: sepal_ui.mapping.SepalMap .. rubric:: Attributes .. autosummary:: - ~FullScreenControl.ICONS - ~FullScreenControl.METHODS - ~FullScreenControl.zoomed - ~FullScreenControl.w_btn - ~FullScreenControl.template + ~SepalMap.ee + ~SepalMap.dc + ~SepalMap.v_inspector .. rubric:: Methods .. autosummary:: :nosignatures: - ~FullScreenControl.toggle_fullscreen + ~SepalMap.remove_last_layer + ~SepalMap.set_center + ~SepalMap.zoom_ee_object + ~SepalMap.zoom_bounds + ~SepalMap.add_raster + ~SepalMap.show_dc + ~SepalMap.hide_dc + ~SepalMap.add_colorbar + ~SepalMap.add_ee_Layer + ~SepalMap.get_basemap_list + ~SepalMap.get_viz_params + ~SepalMap.remove_layer + ~SepalMap.remove_all + ~SepalMap.add_layer + ~SepalMap.add_basemap + ~SepalMap.get_scale + ~SepalMap.setCenter + ~SepalMap.getScale + ~SepalMap.addLayer + ~SepalMap.centerObject -.. automethod:: sepal_ui.mapping.FullScreenControl.toggle_fullscreen +.. automethod:: sepal_ui.mapping.SepalMap.remove_last_layer + +.. automethod:: sepal_ui.mapping.SepalMap.set_center + +.. automethod:: sepal_ui.mapping.SepalMap.zoom_ee_object + +.. automethod:: sepal_ui.mapping.SepalMap.zoom_bounds + +.. automethod:: sepal_ui.mapping.SepalMap.add_raster + +.. automethod:: sepal_ui.mapping.SepalMap.show_dc + +.. automethod:: sepal_ui.mapping.SepalMap.hide_dc + +.. automethod:: sepal_ui.mapping.SepalMap.add_colorbar + +.. automethod:: sepal_ui.mapping.SepalMap.add_ee_Layer + +.. autofunction:: sepal_ui.mapping.SepalMap.get_basemap_list + +.. automethod:: sepal_ui.mapping.SepalMap.get_viz_params + +.. automethod:: sepal_ui.mapping.SepalMap.remove_layer + +.. automethod:: sepal_ui.mapping.SepalMap.remove_all + +.. automethod:: sepal_ui.mapping.SepalMap.add_layer + +.. automethod:: sepal_ui.mapping.SepalMap.add_basemap + +.. automethod:: sepal_ui.mapping.SepalMap.get_scale + +.. automethod:: sepal_ui.mapping.SepalMap.find_layer + +.. automethod:: sepal_ui.mapping.SepalMap.setCenter + +.. automethod:: sepal_ui.mapping.SepalMap.addLayer + +.. automethod:: sepal_ui.mapping.SepalMap.getScale + +.. automethod:: sepal_ui.mapping.SepalMap.centerObject diff --git a/docs/source/modules/sepal_ui.mapping.ValueInspector.rst b/docs/source/modules/sepal_ui.mapping.ValueInspector.rst new file mode 100644 index 00000000..c37faa99 --- /dev/null +++ b/docs/source/modules/sepal_ui.mapping.ValueInspector.rst @@ -0,0 +1,25 @@ +sepal\_ui.mapping.ValueInspector +================================ + +.. autoclass:: sepal_ui.mapping.ValueInspector + + .. rubric:: Attributes + + .. autosummary:: + + ~ValueInspector.m + ~ValueInspector.w_loading + ~ValueInspector.menu + ~ValueInspector.text + + .. rubric:: Methods + + .. autosummary:: + :nosignatures: + + ~ValueInspector.toggle_cursor + ~ValueInspector.read_data + +.. automethod:: sepal_ui.mapping.ValueInspector.toggle_cursor + +.. automethod:: sepal_ui.mapping.ValueInspector.read_data diff --git a/docs/source/modules/sepal_ui.mapping.basemaps.rst b/docs/source/modules/sepal_ui.mapping.basemaps.rst new file mode 100644 index 00000000..0ef92a6c --- /dev/null +++ b/docs/source/modules/sepal_ui.mapping.basemaps.rst @@ -0,0 +1,16 @@ +sepal\_ui.mapping.basemaps +========================== + +.. automodule:: sepal_ui.mapping.basemaps + + .. rubric:: Functions + + .. autosummary:: + :nosignatures: + + get_xyz_dict + xyz_to_leaflet + +.. autofunction:: sepal_ui.mapping.basemaps.get_xyz_dict + +.. autofunction:: sepal_ui.mapping.basemaps.xyz_to_leaflet \ No newline at end of file diff --git a/docs/source/modules/sepal_ui.mapping.rst b/docs/source/modules/sepal_ui.mapping.rst index a3b7eedd..ffed4246 100644 --- a/docs/source/modules/sepal_ui.mapping.rst +++ b/docs/source/modules/sepal_ui.mapping.rst @@ -3,10 +3,22 @@ sepal\_ui.mapping .. automodule:: sepal_ui.mapping +.. rubric:: Modules + +.. autosummary:: + :toctree: + :recursive: + + sepal_ui.mapping.basemaps + .. rubric:: Classes .. autosummary:: :toctree: sepal_ui.mapping.SepalMap - sepal_ui.mapping.FullScreenControl \ No newline at end of file + sepal_ui.mapping.FullScreenControl + sepal_ui.mapping.DrawControl + sepal_ui.mapping.EELayer + sepal_ui.mapping.MapBtn + sepal_ui.mapping.ValueInspector \ No newline at end of file diff --git a/docs/source/modules/sepal_ui.scripts.utils.rst b/docs/source/modules/sepal_ui.scripts.utils.rst index 09b3e100..830ee1ad 100644 --- a/docs/source/modules/sepal_ui.scripts.utils.rst +++ b/docs/source/modules/sepal_ui.scripts.utils.rst @@ -25,6 +25,7 @@ sepal\_ui.scripts.utils set_config_locale set_config_theme set_type + geojson_to_ee .. autofunction:: sepal_ui.scripts.utils.catch_errors @@ -60,19 +61,5 @@ sepal\_ui.scripts.utils .. autofunction:: sepal_ui.scripts.utils.set_type - - - - - - - - - - - - - - - +.. autofunction:: sepal_ui.scripts.utils.geojson_to_ee diff --git a/sepal_ui/aoi/aoi_model.py b/sepal_ui/aoi/aoi_model.py index 21752ae1..ad2a72fb 100644 --- a/sepal_ui/aoi/aoi_model.py +++ b/sepal_ui/aoi/aoi_model.py @@ -7,7 +7,6 @@ import pandas as pd import geopandas as gpd from ipyleaflet import GeoJSON -import geemap import ee from sepal_ui import color @@ -260,6 +259,7 @@ def _from_asset(self, asset_name): if asset_name["value"] is None: raise Exception(ms.aoi_sel.exception.no_value) + # set the name self.name = Path(asset_name["pathname"]).stem.replace(self.ASSET_SUFFIX, "") ee_col = ee.FeatureCollection(asset_name["pathname"]) @@ -274,11 +274,8 @@ def _from_asset(self, asset_name): self.feature_collection = ee_col # create a gdf form te feature_collection - # cannot be used before geemap 0.8.17 (not released) - # self.gdf = geemap.ee_to_geopandas(self.feature_collection) - self.gdf = gpd.GeoDataFrame.from_features( - self.feature_collection.getInfo()["features"] - ).set_crs(epsg=4326) + features = self.feature_collection.getInfo()["features"] + self.gdf = gpd.GeoDataFrame.from_features(features).set_crs(epsg=4326) return self @@ -311,7 +308,7 @@ def _from_points(self, point_json): if self.ee: # transform the gdf to ee.FeatureCollection - self.feature_collection = geemap.geojson_to_ee(self.gdf.__geo_interface__) + self.feature_collection = ee.FeatureCollection(self.gdf.__geo_interface__) # export as a GEE asset self.export_to_asset() @@ -344,7 +341,7 @@ def _from_vector(self, vector_json): if self.ee: # transform the gdf to ee.FeatureCollection - self.feature_collection = geemap.geojson_to_ee(self.gdf.__geo_interface__) + self.feature_collection = su.geojson_to_ee(self.gdf.__geo_interface__) # export as a GEE asset self.export_to_asset() @@ -370,7 +367,7 @@ def _from_geo_json(self, geo_json): if self.ee: # transform the gdf to ee.FeatureCollection - self.feature_collection = geemap.geojson_to_ee(self.gdf.__geo_interface__) + self.feature_collection = su.geojson_to_ee(self.gdf.__geo_interface__) # export as a GEE asset self.export_to_asset() @@ -415,11 +412,8 @@ def _from_admin(self, admin): ).filter(ee.Filter.eq(f"ADM{level}_CODE", admin)) # transform it into gdf - # cannot be used before geemap 0.8.17 (not released) - # self.gdf = geemap.ee_to_geopandas(self.feature_collection) - self.gdf = gpd.GeoDataFrame.from_features( - self.feature_collection.getInfo()["features"] - ).set_crs(epsg=4326) + features = self.feature_collection.getInfo()["features"] + self.gdf = gpd.GeoDataFrame.from_features(features).set_crs(epsg=4326) else: # save the country iso_code diff --git a/sepal_ui/frontend/styles.py b/sepal_ui/frontend/styles.py index 5640d606..661d37e6 100644 --- a/sepal_ui/frontend/styles.py +++ b/sepal_ui/frontend/styles.py @@ -91,6 +91,7 @@ class Styles(v.VuetifyTemplate): .leaflet-top, .leaflet-bottom {z-index : 2 !important;} .leaflet-widgetcontrol {box-shadow: none} main.v-content {padding-top: 0px !important;} + .leaflet-control-container .vuetify-styles .v-application {background: rgb(0,0,0,0);} """ @@ -172,3 +173,11 @@ class Styles(v.VuetifyTemplate): "fill": True, "fillOpacity": 0, } + +map_btn_style = { + "padding": "0px", + "min-width": "0px", + "width": "30px", + "height": "30px", + "background": color.bg, +} diff --git a/sepal_ui/mapping/__init__.py b/sepal_ui/mapping/__init__.py index bb2cf529..28eabed0 100644 --- a/sepal_ui/mapping/__init__.py +++ b/sepal_ui/mapping/__init__.py @@ -1,2 +1,6 @@ -from .mapping import * +from .sepal_map import * from .fullscreen_control import * +from .draw_control import * +from .value_inspector import * +from .layer import * +from .map_btn import * diff --git a/sepal_ui/mapping/basemaps.py b/sepal_ui/mapping/basemaps.py new file mode 100644 index 00000000..9f594eeb --- /dev/null +++ b/sepal_ui/mapping/basemaps.py @@ -0,0 +1,100 @@ +from ipyleaflet import TileLayer +import xyzservices.providers as xyz +from xyzservices import TileProvider +from box import Box + + +class BasemapBox(Box): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __repr__(self): + return ",\n".join(list(self.keys())) + + +xyz_tiles = { + "OpenStreetMap": { + "url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + "attribution": "OpenStreetMap", + "name": "OpenStreetMap", + }, + "ROADMAP": { + "url": "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}", + "attribution": "Google", + "name": "Google Maps", + }, + "SATELLITE": { + "url": "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}", + "attribution": "Google", + "name": "Google Satellite", + }, + "TERRAIN": { + "url": "https://mt1.google.com/vt/lyrs=p&x={x}&y={y}&z={z}", + "attribution": "Google", + "name": "Google Terrain", + }, + "HYBRID": { + "url": "https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}", + "attribution": "Google", + "name": "Google Satellite", + }, +} +"(dict): Custom XYZ tile services." + + +def get_xyz_dict(free_only=True, _collection=None, _output=None): + """ + Returns a dictionary of xyz services. + + Args: + free_only (bool, optional): Whether to return only free xyz tile services that do not require an access token. Defaults to True. + + Returns: + dict: A dictionary of xyz services. + """ + + _collection = xyz if _collection is None else _collection + _output = {} if _output is None else _output + + for v in _collection.values(): + if isinstance(v, TileProvider): + if not (v.requires_token() and free_only): + _output[v.name] = v + else: # it's a Bunch + get_xyz_dict(free_only, v, _output) + + return _output + + +def xyz_to_leaflet(): + """ + Convert all available xyz tile services to ipyleaflet tile layers. + Adapted from https://github.com/giswqs/geemap + + Returns: + dict: A dictionary of ipyleaflet tile layers. + """ + leaflet_dict = {} + + for key in xyz_tiles: + name = xyz_tiles[key]["name"] + url = xyz_tiles[key]["url"] + attribution = xyz_tiles[key]["attribution"] + leaflet_dict[key] = TileLayer( + url=url, name=name, attribution=attribution, max_zoom=22, base=True + ) + + for i, item in get_xyz_dict().items(): + leaflet_dict[item.name] = TileLayer( + url=item.build_url(), + name=item.name, + max_zoom=item.get("max_zoom", 22), + attribution=item.attribution, + base=True, + ) + + return leaflet_dict + + +basemap_tiles = BasemapBox(xyz_to_leaflet(), frozen_box=True) +"(Box.box): the basemaps list as a box" diff --git a/sepal_ui/mapping/draw_control.py b/sepal_ui/mapping/draw_control.py new file mode 100644 index 00000000..31f44b86 --- /dev/null +++ b/sepal_ui/mapping/draw_control.py @@ -0,0 +1,53 @@ +from ipyleaflet import DrawControl + +from sepal_ui import color + + +class DrawControl(DrawControl): + """ + A custom DrawingControl object to handle edition of features + + Args: + m (ipyleaflet.Map): the map on which he drawControl is displayed + kwargs (optional): any available arguments from a ipyleaflet.DrawingControl + """ + + m = None + "(ipyleaflet.Map) the map on which he drawControl is displayed. It will help control the visibility" + + def __init__(self, m, **kwargs): + + # set some default parameters + options = {"shapeOptions": {"color": color.info}} + kwargs["edit"] = kwargs.pop("edit", False) + kwargs["marker"] = kwargs.pop("marker", {}) + kwargs["circlemarker"] = kwargs.pop("circlemarker", {}) + kwargs["polyline"] = kwargs.pop("polyline", {}) + kwargs["rectangle"] = kwargs.pop("rectangle", options) + kwargs["circle"] = kwargs.pop("circle", options) + kwargs["polygon"] = kwargs.pop("polygon", options) + + # save the map in the memeber of the objects + self.m = m + + super().__init__(**kwargs) + + def show(self): + """ + show the drawing control on the map. and clear it's content. + """ + + self.clear() + self in self.m.controls or self.m.add_control(self) + + return + + def hide(self): + """ + hide the drawing control from the map, and clear it's content. + """ + + self.clear() + self not in self.m.controls or self.m.remove_control(self) + + return diff --git a/sepal_ui/mapping/fullscreen_control.py b/sepal_ui/mapping/fullscreen_control.py index 212bc945..6cc8b5ad 100644 --- a/sepal_ui/mapping/fullscreen_control.py +++ b/sepal_ui/mapping/fullscreen_control.py @@ -1,7 +1,8 @@ from ipyleaflet import WidgetControl from IPython.display import display import ipyvuetify as v -from ipywidgets import Button, Layout + +from sepal_ui.mapping.map_btn import MapBtn class FullScreenControl(WidgetControl): @@ -14,10 +15,10 @@ class FullScreenControl(WidgetControl): .. versionadded:: 2.7.0 Args: - kwargs (optional): any availabel arguments from a ipyleaflet WidgetControl + kwargs (optional): any available arguments from a ipyleaflet WidgetControl """ - ICONS = ["arrows-alt", "compress"] + ICONS = ["fas fa-expand", "fas fa-compress"] "list: The icons that will be used to toggle between expand and compressed mode" METHODS = ["embed", "fullscreen"] @@ -35,13 +36,7 @@ class FullScreenControl(WidgetControl): def __init__(self, **kwargs): # create a btn - self.w_btn = Button( - tooltip="set fullscreen", - icon=self.ICONS[self.zoomed], - layout=Layout( - width="30px", height="30px", line_height="30px", padding="0px" - ), - ) + self.w_btn = MapBtn(logo=self.ICONS[self.zoomed]) # overwrite the widget set in the kwargs (if any) kwargs["widget"] = self.w_btn @@ -52,7 +47,7 @@ def __init__(self, **kwargs): super().__init__(**kwargs) # add javascrip behaviour - self.w_btn.on_click(self.toggle_fullscreen) + self.w_btn.on_event("click", self.toggle_fullscreen) # template with js behaviour # "jupyter_fullscreen" place tje "leaflet-container element on the front screen @@ -89,7 +84,7 @@ def __init__(self, **kwargs): ) display(self.template) - def toggle_fullscreen(self, widget): + def toggle_fullscreen(self, widget, event, data): """ Toggle the fullscreen state of the map by sending the required javascript method, changing the w_btn icons and the zoomed state of the control. """ @@ -98,7 +93,7 @@ def toggle_fullscreen(self, widget): self.zoomed = not self.zoomed # change button icon - self.w_btn.icon = self.ICONS[self.zoomed] + self.w_btn.logo.children = [self.ICONS[self.zoomed]] # zoom self.template.send({"method": self.METHODS[self.zoomed], "args": []}) diff --git a/sepal_ui/mapping/layer.py b/sepal_ui/mapping/layer.py new file mode 100644 index 00000000..c3d08177 --- /dev/null +++ b/sepal_ui/mapping/layer.py @@ -0,0 +1,20 @@ +from ipyleaflet import TileLayer + + +class EELayer(TileLayer): + """ + Wrapper of the TileLayer class to add the ee object as a member. + useful to get back the values for specific points in a v_inspector + + Args: + ee_object (ee.object): the ee.object displayed on the map + """ + + ee_object = None + "ee.object: the ee.object displayed on the map" + + def __init__(self, ee_object, **kwargs): + + self.ee_object = ee_object + + super().__init__(**kwargs) diff --git a/sepal_ui/mapping/map_btn.py b/sepal_ui/mapping/map_btn.py new file mode 100644 index 00000000..ab55e1c8 --- /dev/null +++ b/sepal_ui/mapping/map_btn.py @@ -0,0 +1,32 @@ +import ipyvuetify as v + +from sepal_ui import sepalwidgets as sw +from sepal_ui.frontend.styles import map_btn_style + + +class MapBtn(v.Btn, sw.SepalWidget): + """ + Btn specifically design to be displayed on a map. It matches all the characteristics of + the classic leaflet btn but as they are from ipyvuetify we can use them in combination with Menu to produce on-the-map. The MapBtn is responsive to theme changes. + Tiles. It only accept icon as children as the space is very limited. + + Args: + logo (str): a fas/mdi fully qualified name + """ + + logo = None + "(sw.Icon): a sw.Icon" + + def __init__(self, logo, **kwargs): + + # create the icon + self.logo = sw.Icon(small=True, children=[logo]) + + # some parameters are overloaded to match the map requirements + kwargs["color"] = "text-color" + kwargs["outlined"] = True + kwargs["style_"] = " ".join([f"{k}: {v};" for k, v in map_btn_style.items()]) + kwargs["children"] = [self.logo] + kwargs["icon"] = False + + super().__init__(**kwargs) diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/sepal_map.py similarity index 63% rename from sepal_ui/mapping/mapping.py rename to sepal_ui/mapping/sepal_map.py index 20217279..244d303c 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/sepal_map.py @@ -6,257 +6,133 @@ if "PROJ_LIB" in list(os.environ.keys()): del os.environ["PROJ_LIB"] -import collections from pathlib import Path from distutils.util import strtobool import warnings +import math -import geemap from haversine import haversine import numpy as np import rioxarray -import xarray_leaflet # do not remove: plugin for rioxarray so it is never called but always used +import xarray_leaflet import matplotlib.pyplot as plt from matplotlib import colors as mpc from matplotlib import colorbar import ipywidgets as widgets -from ipyleaflet import ( - AttributionControl, - DrawControl, - LayersControl, - LocalTileLayer, - ScaleControl, - WidgetControl, - ZoomControl, - GeoJSON, -) from rasterio.crs import CRS -from traitlets import Bool, link, observe import ipyvuetify as v -import ipyleaflet +import ipyleaflet as ipl import ee +from deprecated.sphinx import deprecated import sepal_ui.frontend.styles as styles from sepal_ui.scripts import utils as su from sepal_ui.scripts.warning import SepalWarning from sepal_ui.message import ms +from sepal_ui.mapping.draw_control import DrawControl +from sepal_ui.mapping.value_inspector import ValueInspector +from sepal_ui.mapping.layer import EELayer +from sepal_ui.mapping.basemaps import basemap_tiles __all__ = ["SepalMap"] -# call x_array leaflet at least one +# call x_array leaflet at least once # flake8 will complain as it's a pluggin (i.e. never called) # We don't want to ignore testing F401 xarray_leaflet -class SepalMap(geemap.Map): +class SepalMap(ipl.Map): """ - The SepalMap class inherits from geemap.Map. It can thus be initialized with all its parameter. - The map will fall back to CartoDB.DarkMatter map that well fits with the rest of the sepal_ui layout. - Numerous methods have been added in the class to help you deal with your workflow implementation. - It can natively display raster from .tif files and files and ee objects using methods that have the same signature as the GEE JavaScripts console. + The SepalMap class inherits from ipyleaflet.Map. It can thus be initialized with all + its parameter. + The map will fall back to CartoDB.DarkMatter map that well fits with the rest of + the sepal_ui layout. + Numerous methods have been added in the class to help you deal with your workflow + implementation. + It can natively display raster from .tif files and files and ee objects using methods + that have the same signature as the GEE JavaScripts console. Args: basemaps ['str']: the basemaps used as background in the map. If multiple selection, they will be displayed as layers. dc (bool, optional): wether or not the drawing control should be displayed. default to false vinspector (bool, optional): Add value inspector to map, useful to inspect pixel values. default to false gee (bool, optional): wether or not to use the ee binding. If False none of the earthengine display fonctionalities can be used. default to True - kwargs (optional): any parameter from a geemap.Map. if set, 'ee_initialize' will be overwritten. + kwargs (optional): any parameter from a ipyleaflet.Map. if set, 'ee_initialize' will be overwritten. """ - # ############################################################################ - # ### Map parameters ### - # ############################################################################ + # ########################################################################## + # ### Map parameters ### + # ########################################################################## ee = True - "bool: either the map will use geempa binding or not" + "bool: either the map will use ee binding or not" - vinspector = Bool(False).tag(sync=True) - "bool: either or not the datainspector is available" - - loaded_rasters = {} - "dict: the list of loaded rasters" + v_inspector = None + "mapping.ValueInspector: the value inspector of the map" dc = None "ipyleaflet.DrawingControl: the drawing control of the map" def __init__(self, basemaps=[], dc=False, vinspector=False, gee=True, **kwargs): - self.world_copy_jump = True - # set the default parameters - kwargs["ee_initialize"] = False # we do it ourselves - kwargs["add_google_map"] = kwargs.pop("add_google_map", False) kwargs["center"] = kwargs.pop("center", [0, 0]) kwargs["zoom"] = kwargs.pop("zoom", 2) + kwargs["basemap"] = {} + kwargs["zoom_control"] = False + kwargs["attribution_control"] = False + kwargs["scroll_wheel_zoom"] = True + kwargs["world_copy_jump"] = kwargs.pop("world_copy_jump", True) # Init the map super().__init__(**kwargs) # init ee self.ee = gee - if gee: - su.init_ee() + not gee or su.init_ee() # add the basemaps self.clear_layers() - if len(basemaps): - [self.add_basemap(basemap) for basemap in set(basemaps)] - else: - default_basemap = ( - "CartoDB.DarkMatter" if v.theme.dark is True else "CartoDB.Positron" - ) - self.add_basemap(default_basemap) + default_basemap = ( + "CartoDB.DarkMatter" if v.theme.dark is True else "CartoDB.Positron" + ) + basemaps = basemaps or [default_basemap] + [self.add_basemap(basemap) for basemap in set(basemaps)] # add the base controls - self.clear_controls() - self.add_control(ZoomControl(position="topright")) - self.add_control(LayersControl(position="topright")) - self.add_control(AttributionControl(position="bottomleft")) - self.add_control(ScaleControl(position="bottomleft", imperial=False)) - - # change the prefix - for control in self.controls: - if type(control) == AttributionControl: - control.prefix = "SEPAL" + self.add_control(ipl.ZoomControl(position="topright")) + self.add_control(ipl.LayersControl(position="topright")) + self.add_control(ipl.AttributionControl(position="bottomleft", prefix="SEPAL")) + self.add_control(ipl.ScaleControl(position="bottomleft", imperial=False)) # specific drawing control - self.set_drawing_controls(dc) - - # Add value inspector - self.w_vinspector = widgets.Checkbox( - value=False, - description="Inspect values", - indent=False, - layout=widgets.Layout(width="18ex"), - ) - - if vinspector: - self.add_control( - WidgetControl(widget=self.w_vinspector, position="topright") - ) - - link((self.w_vinspector, "value"), (self, "vinspector")) - - # Create output space for raster interaction - self.output_r = widgets.Output(layout={"border": "1px solid black"}) - self.output_control_r = WidgetControl( - widget=self.output_r, position="bottomright" - ) - self.add_control(self.output_control_r) - - # define interaction with rasters - self.on_interaction(self._raster_interaction) - - @observe("vinspector") - def _change_cursor(self, change): - """Method to be called when vinspector trait changes""" - - if self.vinspector: - self.default_style = {"cursor": "crosshair"} - else: - self.default_style = {"cursor": "grab"} - - return - - def _raster_interaction(self, **kwargs): - """Define a behavior when ispector checked and map clicked""" - - if kwargs.get("type") == "click" and self.vinspector: - latlon = kwargs.get("coordinates") - self.default_style = {"cursor": "wait"} - - local_rasters = [ - lr.name for lr in self.layers if isinstance(lr, LocalTileLayer) - ] - - if local_rasters: - - with self.output_r: - self.output_r.clear_output(wait=True) - - for lr_name in local_rasters: - - lr = self.loaded_rasters[lr_name] - lat, lon = latlon - - # Verify if the selected latlon is the image bounds - if any( - [ - lat < lr.bottom, - lat > lr.top, - lon < lr.left, - lon > lr.right, - ] - ): - print("Location out of raster bounds") - else: - # row in pixel coordinates - y = int(((lr.top - lat) / abs(lr.y_res))) - - # column in pixel coordinates - x = int(((lon - lr.left) / abs(lr.x_res))) - - # get height and width - h, w = lr.data.shape - value = lr.data[y][x] - print(f"{lr_name}") - print(f"Lat: {round(lat,4)}, Lon: {round(lon,4)}") - print(f"x:{x}, y:{y}") - print(f"Pixel value: {value}") - else: - with self.output_r: - self.output_r.clear_output() - - self.default_style = {"cursor": "crosshair"} + self.dc = DrawControl(self) + not dc or self.add_control(self.dc) - return - - def set_drawing_controls(self, add=False): - """ - Create a drawing control for the map. - It will be possible to draw rectangles, circles and polygons. + # specific v_inspector + self.v_inspector = ValueInspector(self) + not vinspector or self.add_control(self.v_inspector) - Args: - add (bool): either to add the dc to the object attribute or not - - return: - self + @deprecated(version="2.8.0", reason="the local_layer stored list has been dropped") + def _remove_local_raster(self, local_layer): """ + Remove local layer from memory. - color = v.theme.themes.dark.info + .. danger:: - dc = DrawControl( - edit=False, - marker={}, - circlemarker={}, - polyline={}, - rectangle={"shapeOptions": {"color": color}}, - circle={"shapeOptions": {"color": color}}, - polygon={"shapeOptions": {"color": color}}, - ) - - self.dc = dc if add else None - - return self - - def _remove_local_raster(self, local_layer): - """ - Remove local layer from memory + Does nothing now. Args: - local_layer (str | geemap.layer): The local layer to remove or its name + local_layer (str | ipyleaflet.TileLayer): The local layer to remove or its name Return: self """ - name = local_layer if type(local_layer) == str else local_layer.name - - if name in self.loaded_rasters.keys(): - self.loaded_rasters.pop(name) return self + @deprecated(version="2.8.0", reason="use remove_layer(-1) instead") def remove_last_layer(self, local=False): """ Remove last added layer from Map @@ -267,29 +143,24 @@ def remove_last_layer(self, local=False): Return: self """ - if len(self.layers) > 1: + self.remove_layer(-1) - last_layer = self.layers[-1] + return self - if local: - local_rasters = [ - lr for lr in self.layers if isinstance(lr, LocalTileLayer) - ] - if local_rasters: - last_layer = local_rasters[-1] - self.remove_layer(last_layer) + def set_center(self, lon, lat, zoom=None): + """ + Centers the map view at a given coordinates with the given zoom level. - # If last layer is local_layer, remove it from memory - if isinstance(last_layer, LocalTileLayer): - self._remove_local_raster(last_layer) - else: - self.remove_layer(last_layer) + Args: + lon (float): The longitude of the center, in degrees. + lat (float): The latitude of the center, in degrees. + zoom (int|optional): The zoom level, from 1 to 24. Defaults to None. + """ - # If last layer is local_layer, remove it from memory - if isinstance(last_layer, LocalTileLayer): - self._remove_local_raster(last_layer) + self.center = [lat, lon] + self.zoom = self.zoom if zoom is None else zoom - return self + return @su.need_ee def zoom_ee_object(self, ee_geometry, zoom_out=1): @@ -304,9 +175,6 @@ def zoom_ee_object(self, ee_geometry, zoom_out=1): self """ - # center the image - self.centerObject(ee_geometry) - # extract bounds from ee_object ee_bounds = ee_geometry.bounds().coordinates() coords = ee_bounds.get(0).getInfo() @@ -371,7 +239,7 @@ def add_raster( opacity=1.0, fit_bounds=True, get_base_url=lambda _: "https://sepal.io/api/sandbox/jupyter", - colorbar_position="bottomright", + colorbar_position=False, ): """ Adds a local raster dataset to the map. @@ -379,24 +247,24 @@ def add_raster( Args: image (str | pathlib.Path): The image file path. bands (int or list, optional): The image bands to use. It can be either a number (e.g., 1) or a list (e.g., [3, 2, 1]). Defaults to None. - layer_name (str, optional): The layer name to use for the raster. Defaults to None. + layer_name (str, optional): The layer name to use for the raster. Defaults to None. If a layer is already using this name 3 random letter will be added colormap (str, optional): The name of the colormap to use for the raster, such as 'gray' and 'terrain'. More can be found at https://matplotlib.org/3.1.0/tutorials/colors/colormaps.html. Defaults to None. x_dim (str, optional): The x dimension. Defaults to 'x'. y_dim (str, optional): The y dimension. Defaults to 'y'. opacity (float, optional): the opacity of the layer, default 1.0. fit_bounds (bool, optional): Wether or not we should fit the map to the image bounds. Default to True. get_base_url (callable, optional): A function taking the window URL and returning the base URL to use. It's design to work in the SEPAL environment, you only need to change it if you want to work outside of our platform. See xarray-leaflet lib for more details. - colorbar_position (str, optional): The position of the colorbar (default to "bottomright"). set to False to remove it. + colorbar_position (str, optional): The position of the colorbar. By default set to False to remove it. """ - if type(image) == str: - image = Path(image) + # force cast to Path + image = Path(image) if not image.is_file(): raise Exception(ms.mapping.no_image) # check inputs - if layer_name in self.loaded_rasters.keys(): + if layer_name in [layer.name for layer in self.layers]: layer_name = layer_name + su.random_string() if isinstance(colormap, str): @@ -404,8 +272,10 @@ def add_raster( da = rioxarray.open_rasterio(image, masked=True) - # The dataset can be too big to hold in memory, so we will chunk it into smaller pieces. - # That will also improve performances as the generation of a tile can be done in parallel using Dask. + # The dataset can be too big to hold in memory, so we will chunk it into smaller + # pieces. + # That will also improve performances as the generation of a tile can be done + # in parallel using Dask. da = da.chunk((1000, 1000)) # unproject if necessary @@ -413,14 +283,6 @@ def add_raster( if da.rio.crs != CRS.from_string(epsg_4326): da = da.rio.reproject(epsg_4326) - # Create a named tuple with raster bounds and resolution - local_raster = collections.namedtuple( - "LocalRaster", - ("name", "left", "bottom", "right", "top", "x_res", "y_res", "data"), - )(layer_name, *da.rio.bounds(), *da.rio.resolution(), da.data[0]) - - self.loaded_rasters[layer_name] = local_raster - multi_band = False if len(da.band) > 1 and type(bands) != int: multi_band = True @@ -441,50 +303,35 @@ def add_raster( "y_dim": y_dim, "fit_bounds": fit_bounds, "get_base_url": get_base_url, - # 'colorbar_position': colorbar_position, # will be uncoment when the colobared version of xarray-leaflet will be released + "colorbar_position": colorbar_position, "rgb_dim": "band" if multi_band else None, "colormap": None if multi_band else colormap, } # display the layer on the map layer = da.leaflet.plot(**kwargs) - layer.name = layer_name - layer.opacity = opacity if abs(opacity) <= 1.0 else 1.0 + # add the da to the layer as an extra member for the v_inspector + layer.raster = str(image) + return + @deprecated(version="2.8.0", reason="use dc methods instead") def show_dc(self): """ show the drawing control on the map - - Return: - self """ - - if self.dc: - self.dc.clear() - - if self.dc not in self.controls: - self.add_control(self.dc) - + self.dc.show() return self + @deprecated(version="2.8.0", reason="use dc methods instead") def hide_dc(self): """ hide the drawing control of the map - - Return: - self """ - - if self.dc: - self.dc.clear() - - if self.dc in self.controls: - self.remove_control(self.dc) - + self.dc.hide() return self def add_colorbar( @@ -519,7 +366,6 @@ def add_colorbar( """ width, height = 6.0, 0.4 - alpha = 1 if colors is not None: @@ -542,9 +388,8 @@ def add_colorbar( norm = mpc.Normalize(vmin=vmin, vmax=vmax) else: - raise ValueError( - 'cmap keyword or "palette" key in vis_params must be provided.' - ) + msg = '"cmap" keyword or "colors" key must be provided.' + raise ValueError(msg) style = "dark_background" if v.theme.dark is True else "classic" @@ -565,11 +410,10 @@ def add_colorbar( fig.patch.set_alpha(0.0) # remove bg of the fig ax.patch.set_alpha(0.0) # remove bg of the ax - if layer_name: - cb.set_label(layer_name) + not layer_name or cb.set_label(layer_name) output = widgets.Output() - colormap_ctrl = ipyleaflet.WidgetControl( + colormap_ctrl = ipl.WidgetControl( widget=output, position=position, transparent_bg=True, @@ -578,17 +422,11 @@ def add_colorbar( output.clear_output() plt.show() - self.colorbar = colormap_ctrl - if layer_name in self.ee_layer_names: - if "colorbar" in self.ee_layer_dict[layer_name]: - self.remove_control(self.ee_layer_dict[layer_name]["colorbar"]) - self.ee_layer_dict[layer_name]["colorbar"] = colormap_ctrl - self.add_control(colormap_ctrl) return - def addLayer( + def add_ee_Layer( self, ee_object, vis_params={}, @@ -598,8 +436,10 @@ def addLayer( viz_name=False, ): """ - Override the addLayer method from geemap to read the guess the vizaulization parameters the same way as in SEPAL recipes. - If the vizparams are empty and vizualization metadata exist, SepalMap will use them automatically. + Copy the addLayer method from geemap to read and guess the vizaulization + parameters the same way as in SEPAL recipes. + If the vizparams are empty and vizualization metadata exist, SepalMap will use + them automatically. Args: ee_object (ee.Object): the ee OBject to draw on the map @@ -695,8 +535,74 @@ def addLayer( ee_object = asset.select(vis_params["bands"]).hsvToRgb() vis_params["bands"] = ["red", "green", "blue"] - # call the function using the replacing the empty viz params with the new one. - super().addLayer(ee_object, vis_params, name, shown, opacity) + # create the layer based on these new values + image = None + + if name is None: + layer_count = len(self.layers) + name = "Layer " + str(layer_count + 1) + + # check the type of the ee object and raise an error if it's not recognized + if not isinstance( + ee_object, + ( + ee.Image, + ee.ImageCollection, + ee.FeatureCollection, + ee.Feature, + ee.Geometry, + ), + ): + raise AttributeError( + "\n\nThe image argument in 'addLayer' function must be an instance of " + "one of ee.Image, ee.Geometry, ee.Feature or ee.FeatureCollection." + ) + + # force cast to featureCollection if needed + if isinstance( + ee_object, + ( + ee.geometry.Geometry, + ee.feature.Feature, + ee.featurecollection.FeatureCollection, + ), + ): + + features = ee.FeatureCollection(ee_object) + + width = vis_params.pop("width", 2) + color = vis_params.pop("color", "000000") + + const_image = ee.Image.constant(0.5) + image_fill = features.style(fillColor=color).updateMask(const_image) + image_outline = features.style( + color=color, fillColor="00000000", width=width + ) + + image = image_fill.blend(image_outline) + obj = features + + # use directly the ee object if Image + elif isinstance(ee_object, ee.image.Image): + image = obj = ee_object + + # use mosaicing if the ee_object is a ImageCollection + elif isinstance(ee_object, ee.imagecollection.ImageCollection): + image = obj = ee_object.mosaic() + + # create the colored image + map_id_dict = ee.Image(image).getMapId(vis_params) + tile_layer = EELayer( + ee_object=obj, + url=map_id_dict["tile_fetcher"].url_format, + attribution="Google Earth Engine", + name=name, + opacity=opacity, + visible=shown, + max_zoom=24, + ) + + self.add_layer(tile_layer) return @@ -705,17 +611,16 @@ def get_basemap_list(): """ This function is intending for development use It give the list of all the available basemaps for SepalMap object - Return: ([str]): the list of the basemap names """ - return [k for k in geemap.ee_basemaps.keys()] + return [k for k in basemap_tiles.keys()] @staticmethod def get_viz_params(image): """ - Return the vizual parmaeters that are set in the metadata of the image + Return the vizual parameters that are set in the metadata of the image Args: image (ee.Image): the image to analyse @@ -778,28 +683,137 @@ def get_viz_params(image): props[i]["type"] = "rgb" else: warnings.warn( - "the embed viz properties are incomplete or badly set, please review our documentation", + "the embed viz properties are incomplete or badly set, " + "please review our documentation", SepalWarning, ) props = {} return props + def remove_layer(self, key, base=False, none_ok=False): + """ + Remove a layer based on a key. The key can be, a Layer object, the name of a + layer or the index in the layer list + + Args: + key (Layer, int, str): the key to find the layer to delete + base (bool, optional): either the basemaps should be included in the search or not. default t false + none_ok (bool, optional): if True the function will not raise error if no layer is found. Default to False + """ + + layer = self.find_layer(key, base, none_ok) + + # catch if the layer doesn't exist + if layer is None: + raise ipl.LayerException(f"layer not on map: {key}") + + super().remove_layer(layer) + + return + + def remove_all(self, base=False): + """ + Remove all the layers from the maps. + If base is set to True, the basemaps are removed as well + + Args: + base (bool, optional): wether or not the basemaps should be removed, default to False + """ + # filter out the basemaps if base == False + layers = self.layers if base else [lyr for lyr in self.layers if not lyr.base] + + # remove them using the layer objects as keys + [self.remove_layer(layer, base) for layer in layers] + + return + def add_layer(self, layer, hover=False): - """Add layer and use a default style for the GeoJSON inputs + """ + Add layer and use a default style for the GeoJSON inputs. + Remove existing layer if already on the map. + layer (ipyleaflet.Layer): any layer type from ipyleaflet hover (bool): whether to use the default hover style or not. + """ + + # remove existing layer before addition + existing_layer = self.find_layer(layer.name, none_ok=True) + not existing_layer or self.remove_layer(existing_layer) + + # apply default coloring for geoJson + if isinstance(layer, ipl.GeoJSON): + layer.style = layer.style or styles.layer_style + hover_style = styles.layer_hover_style if hover else layer.hover_style + layer.hover_style = layer.hover_style or hover_style + + super().add_layer(layer) + + return + def add_basemap(self, basemap="HYBRID"): """ + Adds a basemap to the map. - if isinstance(layer, GeoJSON): + Args: + basemap (str, optional): Can be one of string from basemaps. Defaults to 'HYBRID'. + """ + if basemap not in basemap_tiles.keys(): + keys = "\n".join(basemap_tiles.keys()) + msg = f"Basemap can only be one of the following:\n{keys}" + raise ValueError(msg) - if not layer.style: - layer.style = styles.layer_style + self.add_layer(basemap_tiles[basemap]) - if hover and not layer.hover_style: - layer.hover_style = styles.layer_hover_style + return - super().add_layer(layer) + def get_scale(self): + """ + Returns the approximate pixel scale of the current map view, in meters. + Reference: https://blogs.bing.com/maps/2006/02/25/map-control-zoom-levels-gt-resolution - return self + Returns: + (float): Map resolution in meters. + """ + + return 156543.04 * math.cos(0) / math.pow(2, self.zoom) + + def find_layer(self, key, base=False, none_ok=False): + """ + Search a layer by name or index + + Args: + key (Layer, str, int): the layer name, index or directly the layer + base (bool, optional): either the basemaps should be included in the search or not. default to false + none_ok (bool, optional): if True the function will not raise error if no layer is found. Default to False + + Return: + (TileLLayerayer): the first layer using the same name or index else None + """ + + # filter the layers + layers = self.layers if base else [lyr for lyr in self.layers if not lyr.base] + + if isinstance(key, str): + layer = next((lyr for lyr in layers if lyr.name == key), None) + elif isinstance(key, int): + size = len(layers) + layer = layers[key] if -size <= key < size else None + elif isinstance(key, ipl.Layer): + layer = next((lyr for lyr in layers if lyr == key), None) + else: + raise ValueError(f"key must be a int or a str, {type(key)} given") + + if layer is None and none_ok is False: + raise ValueError(f"no layer corresponding to {key} on the map") + + return layer + + # ########################################################################## + # ### overwrite geemap calls ### + # ########################################################################## + + setCenter = set_center + centerObject = zoom_ee_object + addLayer = add_ee_Layer + getScale = get_scale diff --git a/sepal_ui/mapping/value_inspector.py b/sepal_ui/mapping/value_inspector.py new file mode 100644 index 00000000..f848018f --- /dev/null +++ b/sepal_ui/mapping/value_inspector.py @@ -0,0 +1,290 @@ +from ipyleaflet import WidgetControl, GeoJSON, LocalTileLayer +import ee +import geopandas as gpd +from shapely import geometry as sg +import rioxarray +import xarray_leaflet +from rasterio.crs import CRS +import rasterio as rio +import ipyvuetify as v + +from sepal_ui import color +from sepal_ui import sepalwidgets as sw +from sepal_ui.scripts import utils as su +from sepal_ui.mapping.layer import EELayer +from sepal_ui.mapping.map_btn import MapBtn +from sepal_ui.frontend.styles import COMPONENTS +from sepal_ui.message import ms + +# call x_array leaflet at least once +# flake8 will complain as it's a pluggin (i.e. never called) +# We don't want to ignore testing F401 +xarray_leaflet + + +class ValueInspector(WidgetControl): + """ + Widget control displaying a btn on the map. When clicked the menu expand to show the values of each layer available on the map. The menu values will be change when the user click on a location on the map. It can digest any Layer added on a SepalMap. + + Args: + m (ipyleaflet.Map): the map on which he vinspector is displayed to interact with it's layers + """ + + m = None + "(ipyleaflet.Map) the map on which he vinspector is displayed to interact with it's layers" + + w_loading = None + "(vuetify.ProgressLinear): the progress bar on top of the Card" + + menu = None + "(vuetify.Menu): the menu displayed when the map btn is clicked" + + text = None + "(vuetify.CardText): the text element from the card that is edited when the user click on the map" + + def __init__(self, m, **kwargs): + + # load the map + self.m = m + + # set some default parameters + kwargs["position"] = kwargs.pop("position", "bottomright") + + # create a loading to place it on top of the card. It will always be visible + # even when the card is scrolled + self.w_loading = sw.ProgressLinear( + indeterminate=False, + background_color=color.menu, + color=COMPONENTS["PROGRESS_BAR"]["color"][v.theme.dark], + ) + + # create a clickable btn + btn = MapBtn(logo="fas fa-crosshairs", v_on="menu.on") + slot = {"name": "activator", "variable": "menu", "children": btn} + close_btn = sw.Icon(children=["fas fa-times"], small=True) + title = sw.Html(tag="h4", children=[ms.v_inspector.title]) + card_title = sw.CardTitle(children=[title, sw.Spacer(), close_btn]) + self.text = sw.CardText(children=[ms.v_inspector.landing]) + card = sw.Card( + tile=True, + color=color.menu, + max_height="40vh", + children=[card_title, self.text], + min_width="400px", + style_="overflow: auto", + ) + + # assemble everything in a menu + self.menu = sw.Menu( + v_model=False, + value=False, + close_on_click=False, + close_on_content_click=False, + children=[self.w_loading, card], + v_slots=[slot], + offset_x=True, + top="bottom" in kwargs["position"], + bottom="top" in kwargs["position"], + left="right" in kwargs["position"], + right="left" in kwargs["position"], + ) + + super().__init__(widget=self.menu, **kwargs) + + # add js behaviour + self.menu.observe(self.toggle_cursor, "v_model") + self.m.on_interaction(self.read_data) + close_btn.on_event("click", lambda *_: setattr(self.menu, "v_model", False)) + + def toggle_cursor(self, change): + """ + Toggle the cursor display on the map to notify to the user that the inspector + mode is activated + """ + + cursors = [{"cursor": "grab"}, {"cursor": "crosshair"}] + self.m.default_style = cursors[self.menu.v_model] + + return + + def read_data(self, **kwargs): + """ + Read the data when the map is clicked with the vinspector activated + + Args: + kwargs: any arguments from the map interaction + """ + # check if the v_inspector is active + is_click = kwargs.get("type") == "click" + is_active = self.menu.v_model is True + if not (is_click and is_active): + return + + # set the loading mode. Cannot be done as a decorator to avoid + # flickering while moving the cursor on the map + self.w_loading.indeterminate = True + self.m.default_style = {"cursor": "wait"} + + # init the text children + children = [] + + # get the coordinates as (x, y) + lng, lat = coords = [c for c in reversed(kwargs.get("coordinates"))] + + # write the coordinates and the scale + txt = ms.v_inspector.coords.format(round(self.m.get_scale())) + children.append(sw.Html(tag="h4", children=[txt])) + children.append(sw.Html(tag="p", children=[f"[{lng:.3f}, {lat:.3f}]"])) + + # write the layers data + children.append(sw.Html(tag="h4", children=["Layers"])) + layers = [lyr for lyr in self.m.layers if not lyr.base] + for lyr in layers: + children.append(sw.Html(tag="h5", children=[lyr.name])) + + if isinstance(lyr, EELayer): + data = self._from_eelayer(lyr.ee_object, coords) + elif isinstance(lyr, GeoJSON): + data = self._from_geojson(lyr.data, coords) + elif isinstance(lyr, LocalTileLayer): + data = self._from_raster(lyr.raster, coords) + else: + data = {ms.v_inspector.info.header: ms.v_inspector.info.text} + + for k, val in data.items(): + children.append(sw.Html(tag="span", children=[f"{k}: {val}"])) + children.append(sw.Html(tag="br", children=[])) + + # set them in the card + self.text.children = children + + # set back the cursor to crosshair + self.w_loading.indeterminate = False + self.m.default_style = {"cursor": "crosshair"} + + # one last flicker to replace the menu next to the btn + # if not it goes below the map + # i've try playing with the styles but it didn't worked out well + # lost hours on this issue : 1h + self.menu.v_model = False + self.menu.v_model = True + + return + + @su.need_ee + def _from_eelayer(self, ee_obj, coords): + """ + extract the values of the ee_object for the considered point + + Args: + ee_obj (ee.object): the ee object to reduce to a single point + coords (tuple): the coordinates of the point (lng, lat). + + Return: + (dict): tke value associated to the image/feature names + """ + + # create a gee point + ee_point = ee.Geometry.Point(*coords) + + if isinstance(ee_obj, ee.FeatureCollection): + + # filter all the value to the point + features = ee_obj.filterBounds(ee_point) + + # if there is none, print non for every property + if features.size().getInfo() == 0: + cols = ee_obj.first().propertyNames().getInfo() + pixel_values = {c: None for c in cols if c not in ["system:index"]} + + # else simply return all the values of the first element + else: + pixel_values = features.first().toDictionary().getInfo() + + elif isinstance(ee_obj, ee.Image): + + # reduce the layer region using mean + pixel_values = ee_obj.reduceRegion( + geometry=ee_point, + scale=self.m.get_scale(), + reducer=ee.Reducer.mean(), + ).getInfo() + + else: + raise ValueError( + f'the layer object is a "{type(ee_obj)}" which is not accepted.' + ) + + return pixel_values + + def _from_geojson(self, data, coords): + """ + extract the values of the data for the considered point + + Args: + data (GeoJSON): the shape to reduce to a single point + coords (tuple): the coordinates of the point (lng, lat). + + Return: + (dict): tke value associated to the feature names + """ + + # extract the coordinates as a poin + point = sg.Point(*coords) + + # filter the data to 1 point + gdf = gpd.GeoDataFrame.from_features(data) + gdf_filtered = gdf[gdf.contains(point)] + skip_cols = ["geometry", "style"] + + # only display the columns name if empty + if len(gdf_filtered) == 0: + cols = gdf.columns.to_list() + return {c: None for c in cols if c not in skip_cols} + + # else print the values of the first element + else: + return gdf_filtered.iloc[0, ~gdf.columns.isin(skip_cols)].to_dict() + + def _from_raster(self, raster, coords): + """ + extract the values of the data-array for the considered point + + Args: + raster (str): the path to the image to reduce to a single point + coords (tuple): the coordinates of the point (lng, lat). + + Return: + (dict): tke value associated to the feature names + """ + + # extract the coordinates as a point + point = sg.Point(*coords) + + # extract the pixel size in degrees (equatorial appoximation) + scale = self.m.get_scale() * 0.00001 + + # open the image and unproject it + da = rioxarray.open_rasterio(raster, masked=True) + da = da.chunk((1000, 1000)) + if da.rio.crs != CRS.from_string("EPSG:4326"): + da = da.rio.reproject("EPSG:4326") + + # sample is not available for da so I do as in GEE a mean reducer around 1px + # is it an overkill ? yes + if sg.box(*da.rio.bounds()).contains(point): + bounds = point.buffer(scale).bounds + window = rio.windows.from_bounds(*bounds, transform=da.rio.transform()) + da_filtered = da.rio.isel_window(window) + means = da_filtered.mean(axis=(1, 2)).to_numpy() + pixel_values = { + ms.v_inspector.band.format(i + 1): v for i, v in enumerate(means) + } + + # if the point is out of the image display None + else: + pixel_values = { + ms.v_inspector.band.format(i + 1): None for i in range(da.rio.count) + } + + return pixel_values diff --git a/sepal_ui/message/en/v_inspector.json b/sepal_ui/message/en/v_inspector.json new file mode 100644 index 00000000..0babd84a --- /dev/null +++ b/sepal_ui/message/en/v_inspector.json @@ -0,0 +1,12 @@ +{ + "v_inspector": { + "title": "Inspector", + "landing": "select a point", + "info": { + "header": "info", + "text": "data reading method not yet ready" + }, + "band": "band {}", + "coords": "Coordinates (lng, lat) at {} m/px" + } +} \ No newline at end of file diff --git a/sepal_ui/scripts/utils.py b/sepal_ui/scripts/utils.py index dca446d0..0bf75e5c 100644 --- a/sepal_ui/scripts/utils.py +++ b/sepal_ui/scripts/utils.py @@ -574,3 +574,49 @@ def set_type(color): color = TYPES[0] return color + + +@versionadded(version="2.8.0") +def geojson_to_ee(geo_json, geodesic=False, encoding="utf-8"): + """ + Transform a geojson object into a featureCollection or a Geometry + No sanity check is performed on the initial geo_json. It must respect the + `__geo_interface__ `__. + + Args: + geo_json (dict): a geo_json dictionnary + geodesic (bool, optional): Whether line segments should be interpreted as spherical geodesics. If false, indicates that line segments should be interpreted as planar lines in the specified CRS. If absent, defaults to True if the CRS is geographic (including the default EPSG:4326), or to False if the CRS is projected. Defaults to False. + encoding (str, optional): The encoding of characters. Defaults to "utf-8". + + Returns: + (ee.FeatureCollection): the created featurecollection + """ + + # from a featureCollection + if geo_json["type"] == "FeatureCollection": + for feature in geo_json["features"]: + if feature["geometry"]["type"] != "Point": + feature["geometry"]["geodesic"] = geodesic + features = ee.FeatureCollection(geo_json) + return features + + # from a single feature + elif geo_json["type"] == "Feature": + geom = None + # Checks whether it is a point + if geo_json["geometry"]["type"] == "Point": + coordinates = geo_json["geometry"]["coordinates"] + longitude = coordinates[0] + latitude = coordinates[1] + geom = ee.Geometry.Point(longitude, latitude) + # for every other geometry simply create a geometry + else: + geom = ee.Geometry(geo_json["geometry"], "", geodesic) + + return geom + + # some error handling because we are fancy + else: + raise ValueError("Could not convert the geojson to ee.Geometry()") + + return diff --git a/setup.py b/setup.py index a0f817bd..0b9507a5 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,6 @@ def run(self): "haversine", "ipyvue>=1.7.0", # this is the version with the class manager "ipyvuetify", # it will work anyway as the widgets are build on the fly - "geemap==0.8.9", "earthengine-api", "markdown", "xarray_leaflet", @@ -47,8 +46,11 @@ def run(self): "natsort", "pipreqs", "cryptography", + "python-box", + "xyzservices", "planet", "pyyaml", + "dask", ], "extras_require": { "dev": [ @@ -107,7 +109,6 @@ def run(self): "Intended Audience :: Developers", "Topic :: Software Development :: Build Tools", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", diff --git a/tests/test_DrawControl.py b/tests/test_DrawControl.py new file mode 100644 index 00000000..8ebb4e1d --- /dev/null +++ b/tests/test_DrawControl.py @@ -0,0 +1,42 @@ +from sepal_ui import mapping as sm + + +class TestDrawControl: + def test_init(self): + + m = sm.SepalMap() + draw_control = sm.DrawControl(m) + assert isinstance(draw_control, sm.DrawControl) + + return + + def test_show(self): + + m = sm.SepalMap() + draw_control = sm.DrawControl(m) + + # add it to the map + draw_control.show() + assert draw_control in m.controls + + # check that it's not added twice + draw_control.show() + assert m.controls.count(draw_control) == 1 + + return + + def test_hide(self): + + m = sm.SepalMap() + draw_control = sm.DrawControl(m) + m.add_control(draw_control) + + # remove it + draw_control.hide() + assert draw_control not in m.controls + + # check that hide when not on the map doesn not raise error + draw_control.hide() + assert draw_control not in m.controls + + return diff --git a/tests/test_EELayer.py b/tests/test_EELayer.py new file mode 100644 index 00000000..8782f5f5 --- /dev/null +++ b/tests/test_EELayer.py @@ -0,0 +1,19 @@ +import ee + +from sepal_ui import mapping as sm +from sepal_ui.scripts import utils as su + + +class TestEELayer: + @su.need_ee + def test_init(self): + + # create a point gee layer (easier to check) + m = sm.SepalMap() + ee_point = ee.FeatureCollection(ee.Geometry.Point(0, 0)) + m.addLayer(ee_point, {}, "point") + + assert isinstance(m.layers[1], sm.EELayer) + assert m.layers[1].ee_object == ee_point + + return diff --git a/tests/test_FullScreenControl.py b/tests/test_FullScreenControl.py index 0e824632..441aaf85 100644 --- a/tests/test_FullScreenControl.py +++ b/tests/test_FullScreenControl.py @@ -14,7 +14,7 @@ def test_init(self): assert isinstance(control, sm.FullScreenControl) assert control in map_.controls assert control.zoomed is False - assert control.w_btn.icon == "arrows-alt" + assert "fas fa-expand" in control.w_btn.logo.children return @@ -26,15 +26,15 @@ def test_toggle_fullscreen(self): # trigger the click # I cannot test the javascript but i can test everything else - control.toggle_fullscreen(None) + control.toggle_fullscreen(None, None, None) assert control.zoomed is True - assert control.w_btn.icon == "compress" + assert "fas fa-compress" in control.w_btn.logo.children # click again to reset to initial state - control.toggle_fullscreen(None) + control.toggle_fullscreen(None, None, None) assert control.zoomed is False - assert control.w_btn.icon == "arrows-alt" + assert "fas fa-expand" in control.w_btn.logo.children return diff --git a/tests/test_MapBtn.py b/tests/test_MapBtn.py new file mode 100644 index 00000000..0dd605b8 --- /dev/null +++ b/tests/test_MapBtn.py @@ -0,0 +1,11 @@ +from sepal_ui import mapping as sm + + +class TestMapBtn: + def test_map_btn(self): + + map_btn = sm.MapBtn("fas fa-folder") + + assert isinstance(map_btn, sm.MapBtn) + + return diff --git a/tests/test_SepalMap.py b/tests/test_SepalMap.py index ce2d38c4..c98922da 100644 --- a/tests/test_SepalMap.py +++ b/tests/test_SepalMap.py @@ -1,9 +1,11 @@ from pathlib import Path +from random import randint +from urllib.request import urlretrieve +import math import pytest import ee -import geemap -from ipyleaflet import basemaps, basemap_to_tiles, GeoJSON +from ipyleaflet import GeoJSON, LocalTileLayer from sepal_ui import mapping as sm import sepal_ui.frontend.styles as styles @@ -21,10 +23,6 @@ def test_init(self): assert len(m.layers) == 1 assert m.layers[0].name == "CartoDB.DarkMatter" - # check that the map start with a DC - m = sm.SepalMap(dc=True) - assert isinstance(m.dc, geemap.DrawControl) - # check that the map start with several basemaps basemaps = ["CartoDB.DarkMatter", "CartoDB.Positron"] m = sm.SepalMap(basemaps) @@ -32,9 +30,13 @@ def test_init(self): layers_name = [layer.name for layer in m.layers] assert all(b in layers_name for b in basemaps) + # check that the map start with a DC + m = sm.SepalMap(dc=True) + assert m.dc in m.controls + # check that the map starts with a vinspector m = sm.SepalMap(vinspector=True) - assert isinstance(m, sm.SepalMap) + assert m.v_inspector in m.controls # check that a wrong layer raise an error if it's not part of the leaflet basemap list with pytest.raises(Exception): @@ -42,69 +44,17 @@ def test_init(self): return - def test_set_drawing_controls(self): + def test_set_center(self): m = sm.SepalMap() - # check that the dc is not add on false - res = m.set_drawing_controls(False) - - assert res == m - assert not any(isinstance(c, geemap.DrawControl) for c in m.controls) + lat = randint(-90, 90) + lng = randint(-180, 180) + zoom = randint(0, 22) + m.set_center(lng, lat, zoom) - m.set_drawing_controls(True) - assert isinstance(m.dc, geemap.DrawControl) - assert m.dc.rectangle == {"shapeOptions": {"color": "#79b1c9"}} - assert m.dc.polygon == {"shapeOptions": {"color": "#79b1c9"}} - assert m.dc.marker == {} - assert m.dc.polyline == {} - - return - - @pytest.mark.skip(reason="problem dealing with local rasters") - def test_remove_local_raster(self): - # init - m = sm.SepalMap() - - # download the raster - out_dir = Path.home() - dem = out_dir / "dem.tif" - - if not dem.isfile(): - dem_url = "https://drive.google.com/file/d/1vRkAWQYsLWCi6vcTMk8vLxoXMFbdMFn8/view?usp=sharing" - geemap.download_from_gdrive(dem_url, "dem.tif", out_dir, unzip=False) - - # add a raster - m.add_raster(dem, colormap="terrain", layer_name="DEM") - - # remove it using its name - res = m._remove_local_raster("DEM") - - assert res == m - assert len(m.loaded_rasters) == 0 - - # remove the file - dem.unlink() - - return - - def test_remove_last_layer(self): - - # init - m = sm.SepalMap() - - # there is just one (the basemap) so not supposed to move - res = m.remove_last_layer() - - assert res == m - assert len(m.layers) == 1 - - # add 1 layer and remove it - layer = basemap_to_tiles(basemaps.CartoDB.Positron) - m.add_layer(layer) - m.remove_last_layer() - - assert len(m.layers) == 1 + assert m.zoom == zoom + assert m.center == [lat, lng] return @@ -151,101 +101,18 @@ def test_zoom_bounds(self): return @pytest.mark.skip(reason="problem dealing with local rasters") - def test_add_raster(self): - - # create a map - m = sm.SepalMap() - - # load a 1 band raster - out_dir = Path.home() - name = "dem" - dem = out_dir / "dem.tif" - if not dem.is_file(): - dem_url = "https://drive.google.com/file/d/1vRkAWQYsLWCi6vcTMk8vLxoXMFbdMFn8/view?usp=sharing" - geemap.download_from_gdrive(dem_url, "dem.tif", out_dir, unzip=False) - m.add_raster(dem, layer_name=name) - - # check name - assert name in m.loaded_layers - # check the colormap - # check opacity + def test_add_raster(self, rgb, byte): - # add the same one - m.add_raster(dem, layer_name=name) - - # check that repeated name lead to specific strings - - # load a multiband file - name = "landsat" - opacity = 0.5 - landsat = out_dir / "landsat.tif" - if not landsat.is_file(): - landsat_url = "https://drive.google.com/file/d/1EV38RjNxdwEozjc9m0FcO3LFgAoAX1Uw/view?usp=sharing" - geemap.download_from_gdrive( - landsat_url, "landsat.tif", out_dir, unzip=False - ) - m.add_raster(landsat, layer_name=name, opacity=opacity) - - # check that it's displayed - # force opacity of the layer - - m.add_raster(landsat, layer_name=name, opacity=14) - - # test > 1 opacity settings - - return - - def test_show_dc(self): - - # add a map with a dc - m = sm.SepalMap(dc=True) - - # draw something - - # show dc - res = m.show_dc() - - assert res == m - assert m.dc in m.controls - - return - - def hide_dc(self): - - # add a map with a dc - m = sm.SepalMap(dc=True) - - # show dc - m.show_dc() - - # hide it - res = m.hide_dc() - - assert res == m - assert m.dc not in m.controls - - return - - def test_change_cursor(self): - - # add a map m = sm.SepalMap() - # change the vinspector trait - m.vinspector = True - assert m.default_style.get_state("cursor") == {"cursor": "crosshair"} + # add a rgb layer to the map + m.add_raster(rgb, layer_name="rgb") + assert m.layers[1].name == "rgb" + assert isinstance(m.layers[1], LocalTileLayer) - # change it back - m.vinspector = False - assert m.default_style.get_state("cursor") == {"cursor": "grab"} - - return - - def test_get_basemap_list(self): - - res = sm.SepalMap.get_basemap_list() - - assert isinstance(res, list) + # add a byte layer + m.add_raster(byte, layer_name="byte") + assert m.layers[2].name == "byte" return @@ -255,11 +122,11 @@ def test_add_colorbar(self): m = sm.SepalMap() m.add_colorbar(colors=["#fc8d59", "#ffffbf", "#91bfdb"], vmin=0, vmax=5) - assert len(m.controls) == 6 # only thing I can check + assert len(m.controls) == 5 # only thing I can check return - def test_addLayer(self, asset_image_viz): + def test_add_ee_layer(self, asset_image_viz): # create map and image image = ee.Image(asset_image_viz) @@ -282,6 +149,15 @@ def test_addLayer(self, asset_image_viz): return + def test_get_basemap_list(self): + + res = sm.SepalMap.get_basemap_list() + + # last time I checked there were 128 + assert len(res) == 128 + + return + def test_get_viz_params(self, asset_image_viz): image = ee.Image(asset_image_viz) @@ -335,6 +211,34 @@ def test_get_viz_params(self, asset_image_viz): return + def test_remove_layer(self, ee_map_with_layers): + + m = ee_map_with_layers + + # remove using a layer without counting the base + m.remove_layer(0) + assert len(m.layers) == 4 + assert m.layers[0].base is True + + # remove when authorizing selection of bases + m.remove_layer(0, base=True) + assert len(m.layers) == 3 + assert m.layers[0].name == "Classification" + + return + + def test_remove_all(self, ee_map_with_layers): + + m = ee_map_with_layers + + m.remove_all() + assert len(m.layers) == 1 + + m.remove_all(base=True) + assert len(m.layers) == 0 + + return + def test_add_layer(self): m = sm.SepalMap() @@ -386,3 +290,111 @@ def test_add_layer(self): assert new_layer.style == layer_style assert new_layer.hover_style == layer_hover_style + + def test_add_basemap(self): + + m = sm.SepalMap() + m.add_basemap("HYBRID") + + assert len(m.layers) == 2 + assert m.layers[1].name == "Google Satellite" + assert m.layers[1].base is True + + # check that a wrong layer raise an error if it's not part of the leaflet basemap list + with pytest.raises(Exception): + m.add_basemap("TOTO") + + return + + def test_get_scale(self): + + m = sm.SepalMap() + m.zoom = 5 + + assert math.isclose(m.get_scale(), 4891.97) + + return + + def test_find_layer(self, ee_map_with_layers): + + m = ee_map_with_layers + + # search by name + res = m.find_layer("Classification") + assert res.name == "Classification" + + # assert the two ways of handling non existing layer + with pytest.raises(ValueError): + res = m.find_layer("toto") + res = m.find_layer("toto", none_ok=True) + assert res is None + + # search by index + res = m.find_layer(0) + assert res.name == "NDWI harmonics" + + res = m.find_layer(-1) + assert res.name == "RGB" + + # out of bounds + with pytest.raises(ValueError): + res = m.find_layer(50) + + # search by layer + res = m.find_layer(m.layers[2]) + assert res.name == "Classification" + + # search including the basemap + res = m.find_layer(0, base=True) + assert "Carto" in res.name + assert res.base is True + + # search something that is not a key + with pytest.raises(ValueError): + m.find_layer(m) + + return + + @pytest.fixture + def rgb(self): + """add a raster file of the bahamas coming from rasterio test suit""" + + rgb = Path.home() / "rgb.tif" + + if not rgb.is_file(): + file = "https://raw.githubusercontent.com/rasterio/rasterio/master/tests/data/RGB.byte.tif" + urlretrieve(file, rgb) + + yield rgb + + rgb.unlink() + + return + + @pytest.fixture + def byte(self): + """add a raster file of the bahamas coming from rasterio test suit""" + + rgb = Path.home() / "byte.tif" + + if not rgb.is_file(): + file = "https://raw.githubusercontent.com/rasterio/rasterio/master/tests/data/byte.tif" + urlretrieve(file, rgb) + + yield rgb + + rgb.unlink() + + return + + @pytest.fixture + def ee_map_with_layers(self, asset_image_viz): + + image = ee.Image(asset_image_viz) + m = sm.SepalMap() + + # display all the viz available in the image + for viz in sm.SepalMap.get_viz_params(image).values(): + m.addLayer(image, {}, viz["name"], viz_name=viz["name"]) + + return m diff --git a/tests/test_ValueInspector.py b/tests/test_ValueInspector.py new file mode 100644 index 00000000..a47b6a8a --- /dev/null +++ b/tests/test_ValueInspector.py @@ -0,0 +1,166 @@ +import pytest +from pathlib import Path +import ee +from urllib.request import urlretrieve +import math +import geopandas as gpd + +from sepal_ui import mapping as sm +from sepal_ui.scripts import utils as su + + +class TestValueInspector: + def test_init(self): + + m = sm.SepalMap() + value_inspector = sm.ValueInspector(m) + m.add_control(value_inspector) + + assert isinstance(value_inspector, sm.ValueInspector) + + def test_toogle_cursor(self): + + m = sm.SepalMap() + value_inspector = sm.ValueInspector(m) + m.add_control(value_inspector) + + # activate the window + value_inspector.menu.v_model = True + assert m.default_style.cursor == "crosshair" + + # close with the menu + value_inspector.menu.v_model = False + assert m.default_style.cursor == "grab" + + return + + def test_read_data(self): + + # not testing the display of anything here just the interaction + m = sm.SepalMap() + value_inspector = sm.ValueInspector(m) + m.add_control(value_inspector) + + # click anywhere without activation + value_inspector.read_data(type="click", coordinates=[0, 0]) + assert len(value_inspector.text.children) == 1 + + # click when activated + value_inspector.menu.v_model = True + value_inspector.read_data(type="click", coordinates=[0, 0]) + assert len(value_inspector.text.children) == 3 + + return + + @su.need_ee + def test_free_eelayer(self, world_temp, ee_adm2): + + # create a map with a value inspector + m = sm.SepalMap() + value_inspector = sm.ValueInspector(m) + + # check a nodata place on Image + data = value_inspector._from_eelayer(world_temp.mosaic(), [0, 0]) + assert data == {"temperature_2m": None} + + # check vatican city + data = value_inspector._from_eelayer(world_temp.mosaic(), [12.457, 41.902]) + assert data == {"temperature_2m": 296.00286865234375} + + # check a featurecollection on nodata place + data = value_inspector._from_eelayer(ee_adm2, [0, 0]) + assert data == {"ADM2_CODE": None} + + # check the featurecollection on vatican city + data = value_inspector._from_eelayer(ee_adm2, [12.457, 41.902]) + assert data == {"ADM2_CODE": 18350} + + return + + def test_from_geojson(self, adm0_vatican): + + # create a map with a value inspector + m = sm.SepalMap() + value_inspector = sm.ValueInspector(m) + + # check a featurecollection on nodata place + data = value_inspector._from_geojson(adm0_vatican, [0, 0]) + assert data == {"GID_0": None, "NAME_0": None} + + # check the featurecollection on vatican city + data = value_inspector._from_geojson(adm0_vatican, [12.457, 41.902]) + assert data == {"GID_0": "VAT", "NAME_0": "Vatican City"} + + return + + def test_from_raster(self, raster_bahamas): + + # create a map with a value inspector + m = sm.SepalMap() + value_inspector = sm.ValueInspector(m) + + # check a featurecollection on nodata place + data = value_inspector._from_raster(raster_bahamas, [0, 0]) + assert data == {"band 1": None, "band 2": None, "band 3": None} + + # check the featurecollection on vatican city + data = value_inspector._from_raster(raster_bahamas, [-78.072, 24.769]) + assert math.isclose(data["band 1"], 70.46553, rel_tol=1e-5) + assert math.isclose(data["band 2"], 91.41595, rel_tol=1e-5) + assert math.isclose(data["band 3"], 93.08673, rel_tol=1e-5) + + return + + @pytest.fixture + def world_temp(self): + """get the world temperature dataset from GEE""" + + return ( + ee.ImageCollection("ECMWF/ERA5_LAND/HOURLY") + .filter(ee.Filter.date("2020-07-01", "2020-07-02")) + .select("temperature_2m") + ) + + @pytest.fixture + def ee_adm2(self): + """get a featurecollection with only adm2code values""" + + return ee.FeatureCollection("FAO/GAUL/2015/level2").select("ADM2_CODE") + + @pytest.fixture + def raster_bahamas(self): + """add a raster file of the bahamas coming from rasterio test suit""" + + rgb = Path.home() / "rgb.tif" + + if not rgb.is_file(): + file = "https://raw.githubusercontent.com/rasterio/rasterio/master/tests/data/RGB.byte.tif" + urlretrieve(file, rgb) + + yield rgb + + rgb.unlink() + + return + + @pytest.fixture + def adm0_vatican(self): + """create a geojson of vatican city""" + + zip_file = Path.home() / "VAT.zip" + + if not zip_file.is_file(): + urlretrieve( + "https://biogeo.ucdavis.edu/data/gadm3.6/gpkg/gadm36_VAT_gpkg.zip", + zip_file, + ) + + layer_name = "gadm36_VAT_0" + level_gdf = gpd.read_file(f"{zip_file}!gadm36_VAT.gpkg", layer=layer_name) + geojson = level_gdf.__geo_interface__ + + yield geojson + + zip_file.unlink() + + return diff --git a/tests/test_basemaps.py b/tests/test_basemaps.py new file mode 100644 index 00000000..3ef95a08 --- /dev/null +++ b/tests/test_basemaps.py @@ -0,0 +1,18 @@ +from sepal_ui import mapping as sm +from ipyleaflet import TileLayer + + +class TestBasemaps: + def test_get_xyz_dict(self): + + assert len(sm.basemaps.get_xyz_dict()) == 123 + + return + + def test_xyz_to_leaflet(self): + + assert len(sm.basemaps.xyz_to_leaflet()) == 128 + for tile in sm.basemaps.xyz_to_leaflet().values(): + assert isinstance(tile, TileLayer) + + return diff --git a/tests/test_utils.py b/tests/test_utils.py index 1c081071..8bf065d8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -6,6 +6,9 @@ import random import ipyvuetify as v +from shapely import geometry as sg +import ee +import geopandas as gpd from sepal_ui import sepalwidgets as sw from sepal_ui.scripts import utils as su @@ -366,3 +369,37 @@ def test_set_style(self): assert res == "info" return + + @su.need_ee + def test_geojson_to_ee(self): + + # create a point list + points = [sg.Point(i, i + 1) for i in range(4)] + d = {"col1": [str(i) for i in range(len(points))], "geometry": points} + gdf = gpd.GeoDataFrame(d, crs="EPSG:4326") + gdf_buffer = gdf.copy() + gdf_buffer.geometry = gdf_buffer.buffer(0.5) + + # test a featurecollection + ee_feature_collection = su.geojson_to_ee(gdf_buffer.__geo_interface__) + assert isinstance(ee_feature_collection, ee.FeatureCollection) + assert ee_feature_collection.size().getInfo() == len(points) + + # test a feature + feature = gdf_buffer.iloc[:1].__geo_interface__["features"][0] + ee_feature = su.geojson_to_ee(feature) + assert isinstance(ee_feature, ee.Geometry) + + # test a single point + point = sg.Point(0, 1) + point = gdf.iloc[:1].__geo_interface__["features"][0] + ee_point = su.geojson_to_ee(point) + assert isinstance(ee_point, ee.Geometry) + assert ee_point.coordinates().getInfo() == [0, 1] + + # test a badly shaped dict + dict_ = {"type": ""} # minimal feature from __geo_interface__ + with pytest.raises(ValueError): + su.geojson_to_ee(dict_) + + return