From 15ae232ce517a813c38716a5ca2dfb768cd80929 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 4 May 2022 14:49:40 +0000 Subject: [PATCH 01/43] fix: drop usage of geemap + cleaning #455 --- sepal_ui/mapping/basemaps.py | 109 +++++++++++++++++ sepal_ui/mapping/mapping.py | 224 ++++++++++++++++++++++++++--------- setup.py | 3 +- 3 files changed, 277 insertions(+), 59 deletions(-) create mode 100644 sepal_ui/mapping/basemaps.py diff --git a/sepal_ui/mapping/basemaps.py b/sepal_ui/mapping/basemaps.py new file mode 100644 index 00000000..3056b3d4 --- /dev/null +++ b/sepal_ui/mapping/basemaps.py @@ -0,0 +1,109 @@ +import collections +import ipyleaflet +import xyzservices.providers as xyz + + +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): + """ + Returns a dictionary of xyz services. + Adapted from https://github.com/giswqs/geemap + + 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. + """ + + xyz_dict = {} + for item in xyz.values(): + try: + name = item["name"] + tile = eval("xyz." + name) + if eval("xyz." + name + ".requires_token()"): + if free_only: + pass + else: + xyz_dict[name] = tile + else: + xyz_dict[name] = tile + + except Exception: + for sub_item in item: + name = item[sub_item]["name"] + tile = eval("xyz." + name) + if eval("xyz." + name + ".requires_token()"): + if free_only: + pass + else: + xyz_dict[name] = tile + else: + xyz_dict[name] = tile + + xyz_dict = collections.OrderedDict(sorted(xyz_dict.items())) + + return xyz_dict + + +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] = ipyleaflet.TileLayer( + url=url, name=name, attribution=attribution, max_zoom=22 + ) + + xyz_dict = get_xyz_dict() + for item in xyz_dict: + name = xyz_dict[item].name + url = xyz_dict[item].build_url() + attribution = xyz_dict[item].attribution + if "max_zoom" in xyz_dict[item].keys(): + max_zoom = xyz_dict[item]["max_zoom"] + else: + max_zoom = 22 + leaflet_dict[name] = ipyleaflet.TileLayer( + url=url, name=name, max_zoom=max_zoom, attribution=attribution + ) + + return leaflet_dict diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index 20217279..a584a86d 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -10,12 +10,12 @@ 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 @@ -29,29 +29,36 @@ WidgetControl, ZoomControl, GeoJSON, + Map, ) from rasterio.crs import CRS from traitlets import Bool, link, observe import ipyvuetify as v import ipyleaflet import ee +from box import Box 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.basemaps import xyz_to_leaflet __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 +# init the basemaps +basemaps = Box(xyz_to_leaflet(), frozen_box=True) +"(Box.box): the basemaps list as a box" -class SepalMap(geemap.Map): + +class SepalMap(Map): """ - The SepalMap class inherits from geemap.Map. It can thus be initialized with all its parameter. + 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. @@ -61,7 +68,7 @@ class SepalMap(geemap.Map): 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. """ # ############################################################################ @@ -85,41 +92,32 @@ 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"] = {} # 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(AttributionControl(position="bottomleft", prefix="SEPAL")) self.add_control(ScaleControl(position="bottomleft", imperial=False)) - # change the prefix - for control in self.controls: - if type(control) == AttributionControl: - control.prefix = "SEPAL" - # specific drawing control self.set_drawing_controls(dc) @@ -245,7 +243,7 @@ def _remove_local_raster(self, local_layer): Remove local layer from memory 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 @@ -291,6 +289,23 @@ def remove_last_layer(self, local=False): return self + def set_center(self, lon, lat, zoom=None): + """ + Centers the map view at a given coordinates with the given zoom level. + + 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. + """ + + self.center = [lat, lon] + self.zoom = zoom or self.zoom + + return + + setCenter = set_center + @su.need_ee def zoom_ee_object(self, ee_geometry, zoom_out=1): """ @@ -323,6 +338,8 @@ def zoom_ee_object(self, ee_geometry, zoom_out=1): return self + centerObject = zoom_ee_object + def zoom_bounds(self, bounds, zoom_out=1): """ Adapt the zoom to the given bounds. and center the image. @@ -463,11 +480,8 @@ def show_dc(self): self """ - if self.dc: - self.dc.clear() - - if self.dc not in self.controls: - self.add_control(self.dc) + not self.dc or self.dc.clear() + self.dc in self.controls or self.add_control(self.dc) return self @@ -479,11 +493,8 @@ def hide_dc(self): self """ - if self.dc: - self.dc.clear() - - if self.dc in self.controls: - self.remove_control(self.dc) + not self.dc or self.dc.clear() + self.dc not in self.controls or self.remove_control(self.dc) return self @@ -519,7 +530,6 @@ def add_colorbar( """ width, height = 6.0, 0.4 - alpha = 1 if colors is not None: @@ -542,9 +552,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,8 +574,7 @@ 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( @@ -579,16 +587,11 @@ def add_colorbar( 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,7 +601,7 @@ 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. + 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: @@ -695,11 +698,72 @@ 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 any( + [ + isinstance(ee_object, ee.Image), + isinstance(ee_object, ee.ImageCollection), + isinstance(ee_object, ee.FeatureCollection), + isinstance(ee_object, ee.Feature), + isinstance(ee_object, ee.Geometry), + ] + ): + err_str = "\n\nThe image argument in 'addLayer' function must be an instance of one of ee.Image, ee.Geometry, ee.Feature or ee.FeatureCollection." + raise AttributeError(err_str) + + # force cast to featureCollection if needed + if any( + [ + isinstance(ee_object, ee.geometry.Geometry), + isinstance(ee_object, ee.feature.Feature), + isinstance(ee_object, 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) + + # use directly the ee object if Image + elif isinstance(ee_object, ee.image.Image): + image = ee_object + + # use mosaicing if the ee_object is a ImageCollection + elif isinstance(ee_object, ee.imagecollection.ImageCollection): + image = ee_object.mosaic() + + # create the colored image + map_id_dict = ee.Image(image).getMapId(vis_params) + tile_layer = ipyleaflet.TileLayer( + 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 + addLayer = add_ee_Layer + @staticmethod def get_basemap_list(): """ @@ -710,7 +774,7 @@ def get_basemap_list(): ([str]): the list of the basemap names """ - return [k for k in geemap.ee_basemaps.keys()] + return [k for k in basemaps.keys()] @staticmethod def get_viz_params(image): @@ -786,20 +850,64 @@ def get_viz_params(image): return props 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) + not existing_layer or self.remove_layer(existing_layer) + + # apply default coloring for geoJson if isinstance(layer, 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 - if not layer.style: - layer.style = styles.layer_style + super().add_layer(layer) - if hover and not layer.hover_style: - layer.hover_style = styles.layer_hover_style + return - super().add_layer(layer) + def add_basemap(self, basemap="HYBRID"): + """ + Adds a basemap to the map. - return self + Args: + basemap (str, optional): Can be one of string from basemaps. Defaults to 'HYBRID'. + """ + if basemap not in basemaps.keys(): + keys = "\n".join(basemaps.keys()) + msg = f"Basemap can only be one of the following:\n{keys}" + raise ValueError(msg) + + self.add_layer(basemaps[basemap]) + + return + + 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 + + Returns: + (float): Map resolution in meters. + """ + + return 156543.04 * math.cos(0) / math.pow(2, self.zoom) + + def find_layer(self, name): + """ + Search a layer by name + + Args: + name (str): the layer name + + Return: + (TileLayer): the first layer using the same name + """ + + return next((tl for tl in self.layers if tl.name == name), None) diff --git a/setup.py b/setup.py index 1ceb3109..e19ab5bd 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,6 +46,8 @@ def run(self): "natsort", "pipreqs", "cryptography", + "python-box", + "xyzservices", ], "extras_require": { "dev": [ From e2d5be06c732330944ad39899f05c0ca32394e4c Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 4 May 2022 14:58:49 +0000 Subject: [PATCH 02/43] fix: set the basemaps as basemaps #422 --- sepal_ui/mapping/basemaps.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/sepal_ui/mapping/basemaps.py b/sepal_ui/mapping/basemaps.py index 3056b3d4..574e8081 100644 --- a/sepal_ui/mapping/basemaps.py +++ b/sepal_ui/mapping/basemaps.py @@ -1,5 +1,5 @@ import collections -import ipyleaflet +from ipyleaflet import TileLayer import xyzservices.providers as xyz @@ -89,8 +89,8 @@ def xyz_to_leaflet(): name = xyz_tiles[key]["name"] url = xyz_tiles[key]["url"] attribution = xyz_tiles[key]["attribution"] - leaflet_dict[key] = ipyleaflet.TileLayer( - url=url, name=name, attribution=attribution, max_zoom=22 + leaflet_dict[key] = TileLayer( + url=url, name=name, attribution=attribution, max_zoom=22, base=True ) xyz_dict = get_xyz_dict() @@ -98,12 +98,9 @@ def xyz_to_leaflet(): name = xyz_dict[item].name url = xyz_dict[item].build_url() attribution = xyz_dict[item].attribution - if "max_zoom" in xyz_dict[item].keys(): - max_zoom = xyz_dict[item]["max_zoom"] - else: - max_zoom = 22 - leaflet_dict[name] = ipyleaflet.TileLayer( - url=url, name=name, max_zoom=max_zoom, attribution=attribution + max_zoom = xyz_dict[item].pop("max_zoom", 22) + leaflet_dict[name] = TileLayer( + url=url, name=name, max_zoom=max_zoom, attribution=attribution, base=True ) return leaflet_dict From 00d94df3f1745ddc5b61acb641559468d8bac820 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 4 May 2022 15:24:41 +0000 Subject: [PATCH 03/43] fix: find layer by name and by index --- sepal_ui/mapping/mapping.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index a584a86d..ab50f55e 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -95,6 +95,8 @@ def __init__(self, basemaps=[], dc=False, vinspector=False, gee=True, **kwargs): kwargs["center"] = kwargs.pop("center", [0, 0]) kwargs["zoom"] = kwargs.pop("zoom", 2) kwargs["basemap"] = {} + kwargs["zoom_control"] = False + kwargs["attribution_control"] = False # Init the map super().__init__(**kwargs) @@ -112,7 +114,6 @@ def __init__(self, basemaps=[], dc=False, vinspector=False, gee=True, **kwargs): [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", prefix="SEPAL")) @@ -899,15 +900,23 @@ def get_scale(self): return 156543.04 * math.cos(0) / math.pow(2, self.zoom) - def find_layer(self, name): + def find_layer(self, key): """ - Search a layer by name + Search a layer by name or index Args: - name (str): the layer name + key (str, int): the layer name or the layer index Return: - (TileLayer): the first layer using the same name + (TileLayer): the first layer using the same name or index else None """ - return next((tl for tl in self.layers if tl.name == name), None) + if isinstance(key, str): + layer = next((tl for tl in self.layers if tl.name == key), None) + elif isinstance(key, int): + size = len(self.layers) + layer = self.layers[key] if -size <= key < size else None + else: + raise ValueError(f"key must be a int or a str, {type(key)} given") + + return layer From 7bd77b64c9a5d4ab3efb9bca0c294583c061f7db Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 4 May 2022 15:34:53 +0000 Subject: [PATCH 04/43] refactor: clean the import of ipyleaflet widgets --- sepal_ui/mapping/mapping.py | 43 ++++++++++++++----------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index ab50f55e..cf6337bf 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -20,21 +20,10 @@ 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, - Map, -) from rasterio.crs import CRS from traitlets import Bool, link, observe import ipyvuetify as v -import ipyleaflet +import ipyleaflet as ipl import ee from box import Box @@ -56,7 +45,7 @@ "(Box.box): the basemaps list as a box" -class SepalMap(Map): +class SepalMap(ipl.Map): """ 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. @@ -114,10 +103,10 @@ def __init__(self, basemaps=[], dc=False, vinspector=False, gee=True, **kwargs): [self.add_basemap(basemap) for basemap in set(basemaps)] # add the base controls - self.add_control(ZoomControl(position="topright")) - self.add_control(LayersControl(position="topright")) - self.add_control(AttributionControl(position="bottomleft", prefix="SEPAL")) - self.add_control(ScaleControl(position="bottomleft", imperial=False)) + 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) @@ -132,14 +121,14 @@ def __init__(self, basemaps=[], dc=False, vinspector=False, gee=True, **kwargs): if vinspector: self.add_control( - WidgetControl(widget=self.w_vinspector, position="topright") + ipl.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( + self.output_control_r = ipl.WidgetControl( widget=self.output_r, position="bottomright" ) self.add_control(self.output_control_r) @@ -166,7 +155,7 @@ def _raster_interaction(self, **kwargs): self.default_style = {"cursor": "wait"} local_rasters = [ - lr.name for lr in self.layers if isinstance(lr, LocalTileLayer) + lr.name for lr in self.layers if isinstance(lr, ipl.LocalTileLayer) ] if local_rasters: @@ -225,7 +214,7 @@ def set_drawing_controls(self, add=False): color = v.theme.themes.dark.info - dc = DrawControl( + dc = ipl.DrawControl( edit=False, marker={}, circlemarker={}, @@ -272,20 +261,20 @@ def remove_last_layer(self, local=False): if local: local_rasters = [ - lr for lr in self.layers if isinstance(lr, LocalTileLayer) + lr for lr in self.layers if isinstance(lr, ipl.LocalTileLayer) ] if local_rasters: last_layer = local_rasters[-1] self.remove_layer(last_layer) # If last layer is local_layer, remove it from memory - if isinstance(last_layer, LocalTileLayer): + if isinstance(last_layer, ipl.LocalTileLayer): self._remove_local_raster(last_layer) else: self.remove_layer(last_layer) # If last layer is local_layer, remove it from memory - if isinstance(last_layer, LocalTileLayer): + if isinstance(last_layer, ipl.LocalTileLayer): self._remove_local_raster(last_layer) return self @@ -578,7 +567,7 @@ def add_colorbar( 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, @@ -750,7 +739,7 @@ def add_ee_Layer( # create the colored image map_id_dict = ee.Image(image).getMapId(vis_params) - tile_layer = ipyleaflet.TileLayer( + tile_layer = ipl.TileLayer( url=map_id_dict["tile_fetcher"].url_format, attribution="Google Earth Engine", name=name, @@ -864,7 +853,7 @@ def add_layer(self, layer, hover=False): not existing_layer or self.remove_layer(existing_layer) # apply default coloring for geoJson - if isinstance(layer, 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 From e299af9d7142803394d5def7fe4d2658d61f7961 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 4 May 2022 15:47:11 +0000 Subject: [PATCH 05/43] fix: overwrite remove_layer to use index, name or layer --- sepal_ui/mapping/mapping.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index cf6337bf..7353bd48 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -839,6 +839,31 @@ def get_viz_params(image): return props + def remove_layer(self, key): + """ + 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 + """ + + if isinstance(key, int) or isinstance(key, str): + layer = self.find_layer(key) + elif isinstance(key, ipl.Layer): + layer = key + else: + raise ValueError( + f"Key must be of type 'str', 'int' or 'Layer'. {type(key)} given." + ) + + # 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 add_layer(self, layer, hover=False): """ Add layer and use a default style for the GeoJSON inputs. From aedbc3bb0fb8a77d1985a5ecee7f272ae1267c80 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 4 May 2022 15:56:54 +0000 Subject: [PATCH 06/43] fix: remove_all method to remove all layers but the basemaps --- sepal_ui/mapping/mapping.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index 7353bd48..b773e5d6 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -864,6 +864,22 @@ def remove_layer(self, key): 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 + """ + gen = (tl for tl in self.layers) + gen = gen if base else (tl for tl in self.layers if tl.base is False) + + for layer in gen: + self.remove_layer(layer) + + return + def add_layer(self, layer, hover=False): """ Add layer and use a default style for the GeoJSON inputs. From fafe1da8d563fc25a27931a10628b3221a439735 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 4 May 2022 16:34:26 +0000 Subject: [PATCH 07/43] refactor: move theDrawControl to its own file It will be supporting the drawing methods (editing, polygonize) from there --- sepal_ui/mapping/draw_control.py | 52 ++++++++++++++++++++++++++ sepal_ui/mapping/fullscreen_control.py | 2 +- sepal_ui/mapping/mapping.py | 51 ++++--------------------- 3 files changed, 61 insertions(+), 44 deletions(-) create mode 100644 sepal_ui/mapping/draw_control.py diff --git a/sepal_ui/mapping/draw_control.py b/sepal_ui/mapping/draw_control.py new file mode 100644 index 00000000..5124760a --- /dev/null +++ b/sepal_ui/mapping/draw_control.py @@ -0,0 +1,52 @@ +from ipyleaflet import DrawControl + +from sepal_ui import color + + +class DrawControl(DrawControl): + """ + A custom DrawingControl object to handle edition of features + + Args: + 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..29f65d72 100644 --- a/sepal_ui/mapping/fullscreen_control.py +++ b/sepal_ui/mapping/fullscreen_control.py @@ -14,7 +14,7 @@ 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"] diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index b773e5d6..19df7f6c 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -26,12 +26,14 @@ import ipyleaflet as ipl import ee from box import Box +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.basemaps import xyz_to_leaflet +from sepal_ui.mapping.draw_control import DrawControl __all__ = ["SepalMap"] @@ -109,7 +111,8 @@ def __init__(self, basemaps=[], dc=False, vinspector=False, gee=True, **kwargs): self.add_control(ipl.ScaleControl(position="bottomleft", imperial=False)) # specific drawing control - self.set_drawing_controls(dc) + self.dc = DrawControl(self) + not dc or self.add_control(self.dc) # Add value inspector self.w_vinspector = widgets.Checkbox( @@ -200,34 +203,6 @@ def _raster_interaction(self, **kwargs): 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. - - Args: - add (bool): either to add the dc to the object attribute or not - - return: - self - """ - - color = v.theme.themes.dark.info - - dc = ipl.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 @@ -462,30 +437,20 @@ def add_raster( return + @deprecated(version="2.8.0", reason="use dc methods instead") def show_dc(self): """ show the drawing control on the map - - Return: - self """ - - not self.dc or self.dc.clear() - self.dc in self.controls or 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 """ - - not self.dc or self.dc.clear() - self.dc not in self.controls or self.remove_control(self.dc) - + self.dc.hide() return self def add_colorbar( From f2c66e6fb8e51b67c84edd406a9b31d67c6d91f6 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 4 May 2022 16:39:50 +0000 Subject: [PATCH 08/43] build: drop support for python 3.6 --- .github/workflows/unit.yml | 2 +- setup.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 0dc7aeee..99913a84 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -13,7 +13,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/setup.py b/setup.py index e19ab5bd..3bb9662c 100644 --- a/setup.py +++ b/setup.py @@ -102,7 +102,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", From 3aa674c4fee2a4310a0ba6972cd0368c9c6d0a86 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 4 May 2022 19:59:10 +0000 Subject: [PATCH 09/43] fix: geemap was still called in aoi_model --- sepal_ui/aoi/aoi_model.py | 24 +++++++------------ sepal_ui/mapping/__init__.py | 1 + sepal_ui/scripts/utils.py | 46 ++++++++++++++++++++++++++++++++++++ tests/test_SepalMap.py | 30 +++++++++++------------ 4 files changed, 70 insertions(+), 31 deletions(-) diff --git a/sepal_ui/aoi/aoi_model.py b/sepal_ui/aoi/aoi_model.py index 0ecfdd74..5b4a00b6 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 @@ -257,6 +256,7 @@ def _from_asset(self, asset_name): if asset_name["value"] is None: raise Exception("Please select a value.") + # set the name self.name = Path(asset_name["pathname"]).stem.replace(self.ASSET_SUFFIX, "") ee_col = ee.FeatureCollection(asset_name["pathname"]) @@ -271,13 +271,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) - - # set the name + features = self.feature_collection.getInfo()["features"] + self.gdf = gpd.GeoDataFrame.from_features(features).set_crs(epsg=4326) return self @@ -310,7 +305,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() @@ -343,7 +338,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() @@ -369,7 +364,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() @@ -413,11 +408,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/mapping/__init__.py b/sepal_ui/mapping/__init__.py index bb2cf529..9c52c895 100644 --- a/sepal_ui/mapping/__init__.py +++ b/sepal_ui/mapping/__init__.py @@ -1,2 +1,3 @@ from .mapping import * from .fullscreen_control import * +from .draw_control import * diff --git a/sepal_ui/scripts/utils.py b/sepal_ui/scripts/utils.py index dca446d0..09b0b529 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 + 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 Exception("Could not convert the geojson to ee.Geometry()") + + return diff --git a/tests/test_SepalMap.py b/tests/test_SepalMap.py index ce2d38c4..fe638c1b 100644 --- a/tests/test_SepalMap.py +++ b/tests/test_SepalMap.py @@ -2,7 +2,6 @@ import pytest import ee -import geemap from ipyleaflet import basemaps, basemap_to_tiles, GeoJSON from sepal_ui import mapping as sm @@ -23,7 +22,7 @@ def test_init(self): # check that the map start with a DC m = sm.SepalMap(dc=True) - assert isinstance(m.dc, geemap.DrawControl) + assert isinstance(m.dc, sm.DrawControl) # check that the map start with several basemaps basemaps = ["CartoDB.DarkMatter", "CartoDB.Positron"] @@ -42,6 +41,7 @@ def test_init(self): return + @pytest.mark.skip(reason="the method is now deprecated") def test_set_drawing_controls(self): m = sm.SepalMap() @@ -50,10 +50,10 @@ def test_set_drawing_controls(self): res = m.set_drawing_controls(False) assert res == m - assert not any(isinstance(c, geemap.DrawControl) for c in m.controls) + assert not any(isinstance(c, sm.DrawControl) for c in m.controls) m.set_drawing_controls(True) - assert isinstance(m.dc, geemap.DrawControl) + assert isinstance(m.dc, sm.DrawControl) assert m.dc.rectangle == {"shapeOptions": {"color": "#79b1c9"}} assert m.dc.polygon == {"shapeOptions": {"color": "#79b1c9"}} assert m.dc.marker == {} @@ -70,9 +70,9 @@ def test_remove_local_raster(self): 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) + # 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") @@ -160,9 +160,9 @@ def test_add_raster(self): 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) + # 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 @@ -179,11 +179,11 @@ def test_add_raster(self): 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 - ) + # 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 From 2cfc24d5c9db369f34cb8da05b3360902f05f967 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Thu, 5 May 2022 08:50:07 +0000 Subject: [PATCH 10/43] fix: avoid circular reference --- sepal_ui/mapping/mapping.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index 19df7f6c..676c9af3 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -284,9 +284,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() From 1ae3c9eae391178cc4c6183ba7a4aa8376203615 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Sun, 22 May 2022 14:10:46 +0000 Subject: [PATCH 11/43] refactor: split the gee command override from the rest of SepalMap --- sepal_ui/mapping/mapping.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index 676c9af3..34ff69d8 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -62,9 +62,9 @@ class SepalMap(ipl.Map): 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" @@ -269,8 +269,6 @@ def set_center(self, lon, lat, zoom=None): return - setCenter = set_center - @su.need_ee def zoom_ee_object(self, ee_geometry, zoom_out=1): """ @@ -300,8 +298,6 @@ def zoom_ee_object(self, ee_geometry, zoom_out=1): return self - centerObject = zoom_ee_object - def zoom_bounds(self, bounds, zoom_out=1): """ Adapt the zoom to the given bounds. and center the image. @@ -714,8 +710,6 @@ def add_ee_Layer( return - addLayer = add_ee_Layer - @staticmethod def get_basemap_list(): """ @@ -912,3 +906,12 @@ def find_layer(self, key): raise ValueError(f"key must be a int or a str, {type(key)} given") return layer + + # ########################################################################## + # ### overwrite geemap calls ### + # ########################################################################## + + setCenter = set_center + centerObject = zoom_ee_object + addLayer = add_ee_Layer + getScale = get_scale From f91d90902d71f836c73e4343d3be6759503259e8 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Sun, 22 May 2022 15:16:37 +0000 Subject: [PATCH 12/43] refactor: cleaning --- sepal_ui/mapping/mapping.py | 75 ++++++++++++++----------------------- 1 file changed, 28 insertions(+), 47 deletions(-) diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index 34ff69d8..e6c675d5 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -6,7 +6,6 @@ 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 @@ -203,9 +202,14 @@ def _raster_interaction(self, **kwargs): return + @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 + Remove local layer from memory. + + .. danger:: + + Does nothing now. Args: local_layer (str | ipyleaflet.TileLayer): The local layer to remove or its name @@ -213,13 +217,10 @@ def _remove_local_raster(self, local_layer): 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 @@ -230,27 +231,7 @@ def remove_last_layer(self, local=False): Return: self """ - if len(self.layers) > 1: - - last_layer = self.layers[-1] - - if local: - local_rasters = [ - lr for lr in self.layers if isinstance(lr, ipl.LocalTileLayer) - ] - if local_rasters: - last_layer = local_rasters[-1] - self.remove_layer(last_layer) - - # If last layer is local_layer, remove it from memory - if isinstance(last_layer, ipl.LocalTileLayer): - self._remove_local_raster(last_layer) - else: - self.remove_layer(last_layer) - - # If last layer is local_layer, remove it from memory - if isinstance(last_layer, ipl.LocalTileLayer): - self._remove_local_raster(last_layer) + self.remove_layer(-1) return self @@ -354,7 +335,7 @@ 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'. @@ -364,14 +345,14 @@ def add_raster( colorbar_position (str, optional): The position of the colorbar (default to "bottomright"). 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): @@ -389,12 +370,10 @@ def add_raster( 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 + # 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]) multi_band = False if len(da.band) > 1 and type(bands) != int: @@ -803,10 +782,8 @@ def remove_layer(self, key): key (Layer, int, str): the key to find the layer to delete """ - if isinstance(key, int) or isinstance(key, str): + if isinstance(key, (int, str, ipl.Layer)): layer = self.find_layer(key) - elif isinstance(key, ipl.Layer): - layer = key else: raise ValueError( f"Key must be of type 'str', 'int' or 'Layer'. {type(key)} given." @@ -828,11 +805,13 @@ def remove_all(self, base=False): Args: base (bool, optional): wether or not the basemaps should be removed, default to False """ - gen = (tl for tl in self.layers) - gen = gen if base else (tl for tl in self.layers if tl.base is False) + # filter out the basemaps if base == False + all_layers = (tl for tl in self.layers) + all_layers_no_basmaps = (tl for tl in self.layers if tl.base is False) + gen = all_layers if base is True else all_layers_no_basmaps - for layer in gen: - self.remove_layer(layer) + # remove them using the built generator + [self.remove_layer(layer) for layer in gen] return @@ -891,17 +870,19 @@ def find_layer(self, key): Search a layer by name or index Args: - key (str, int): the layer name or the layer index + key (Layer, str, int): the layer name, index or directly the layer Return: - (TileLayer): the first layer using the same name or index else None + (TileLLayerayer): the first layer using the same name or index else None """ if isinstance(key, str): - layer = next((tl for tl in self.layers if tl.name == key), None) + layer = next((lyr for lyr in self.layers if lyr.name == key), None) elif isinstance(key, int): size = len(self.layers) layer = self.layers[key] if -size <= key < size else None + elif isinstance(key, ipl.Layer): + layer = next((lyr for lyr in self.layers if lyr == key), None) else: raise ValueError(f"key must be a int or a str, {type(key)} given") From 3501f69a8a40087587b9ab3eb3385da634bb56c5 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 23 May 2022 08:25:47 +0000 Subject: [PATCH 13/43] refactor: move the v_inspector away from SepalMap --- sepal_ui/mapping/__init__.py | 1 + sepal_ui/mapping/mapping.py | 118 ++++---------------------------- sepal_ui/mapping/v_inspector.py | 111 ++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 104 deletions(-) create mode 100644 sepal_ui/mapping/v_inspector.py diff --git a/sepal_ui/mapping/__init__.py b/sepal_ui/mapping/__init__.py index 9c52c895..b59185b2 100644 --- a/sepal_ui/mapping/__init__.py +++ b/sepal_ui/mapping/__init__.py @@ -1,3 +1,4 @@ from .mapping import * from .fullscreen_control import * from .draw_control import * +from .v_inspector import * diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index e6c675d5..04a8783f 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -20,7 +20,7 @@ from matplotlib import colorbar import ipywidgets as widgets from rasterio.crs import CRS -from traitlets import Bool, link, observe +from traitlets import Bool import ipyvuetify as v import ipyleaflet as ipl import ee @@ -33,6 +33,8 @@ from sepal_ui.message import ms from sepal_ui.mapping.basemaps import xyz_to_leaflet from sepal_ui.mapping.draw_control import DrawControl +from sepal_ui.mapping.v_inspector import VInspector + __all__ = ["SepalMap"] @@ -71,9 +73,6 @@ class SepalMap(ipl.Map): vinspector = Bool(False).tag(sync=True) "bool: either or not the datainspector is available" - loaded_rasters = {} - "dict: the list of loaded rasters" - dc = None "ipyleaflet.DrawingControl: the drawing control of the map" @@ -113,94 +112,9 @@ def __init__(self, basemaps=[], dc=False, vinspector=False, gee=True, **kwargs): self.dc = DrawControl(self) not dc or self.add_control(self.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( - ipl.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 = ipl.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, ipl.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"} - - return + # specific v_inspector + self.v_inspector = VInspector(self) + not vinspector or self.add_control(self.v_inspector) @deprecated(version="2.8.0", reason="the local_layer stored list has been dropped") def _remove_local_raster(self, local_layer): @@ -369,12 +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]) - multi_band = False if len(da.band) > 1 and type(bands) != int: multi_band = True @@ -646,13 +554,15 @@ def add_ee_Layer( raise AttributeError(err_str) # force cast to featureCollection if needed - if any( - [ - isinstance(ee_object, ee.geometry.Geometry), - isinstance(ee_object, ee.feature.Feature), - isinstance(ee_object, ee.featurecollection.FeatureCollection), - ] + if isinstance( + ee_object, + ( + ee.geometry.Geometry, + ee.feature.Feature, + ee.featurecollection.FeatureCollection, + ), ): + features = ee.FeatureCollection(ee_object) width = vis_params.pop("width", 2) diff --git a/sepal_ui/mapping/v_inspector.py b/sepal_ui/mapping/v_inspector.py new file mode 100644 index 00000000..6c1b2575 --- /dev/null +++ b/sepal_ui/mapping/v_inspector.py @@ -0,0 +1,111 @@ +import ipyvuetify as v +from ipyleaflet import WidgetControl + +from sepal_ui import sepalwidgets as sw +from sepal_ui.scripts import utils as su + + +class VInspector(WidgetControl): + + m = None + "(ipyleaflet.Map) the map on which he vinspector is displayed to interact with it's layers" + + menu = None + + card = None + + text = None + + def __init__(self, m, **kwargs): + + # load the map + self.m = m + + # set some default parameters + kwargs["position"] = kwargs.pop("position", "bottomright") + + # create a clickable btn + icon = sw.Icon(small=True, children=["mdi-cloud-download"]) + btn = v.Btn( + v_on="menu.on", + color="text-color", + outlined=True, + style_="padding: 0px; min-width: 0px; width: 30px; height: 30px;", + children=[icon], + ) + slot = {"name": "activator", "variable": "menu", "children": btn} + title = sw.CardTitle(children=[sw.Html(tag="h4", children=["Inspector"])]) + self.text = sw.CardText(children=["select a point"]) + self.card = sw.Card( + children=[title, self.text], min_width="400px", min_height="200px" + ) + + # assempble everything in a menu + self.menu = sw.Menu( + v_model=False, + value=False, + close_on_click=False, + close_on_content_click=False, + children=[self.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) + + def toggle_cursor(self, change): + """ + Toggle the cursor displa 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 + + @su.switch("loading", on_widgets=["card"]) + def read_data(self, **kwargs): + """ + Read the data when the map is clicked with the vinspector activated + """ + # 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 curosr to loading mode + self.m.default_style = {"cursor": "wait"} + + # init the text children + children = [] + + # write the coordinates + latlon = kwargs.get("coordinates") + children.append(sw.Html(tag="h4", children=["Coordinates"])) + children.append(sw.Html(tag="p", children=[str(latlon)])) + + # write the layers data + children.append(sw.Html(tag="h4", children=["Layers"])) + layers = [lyr for lyr in self.m.layers if lyr.base is False] + for lyr in layers: + children.append(sw.Html(tag="h5", children=[lyr.name])) + children.append( + sw.Html(tag="p", children=["data reading method not yet ready"]) + ) + + # set them in the card + self.text.children = children + + # set back the cursor to crosshair + self.m.default_style = {"cursor": "crosshair"} + + return From 7b80e1ef87ab44169dc7eba3263a24b34de85b4c Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 23 May 2022 08:51:48 +0000 Subject: [PATCH 14/43] fix: remove background for btns on maps --- sepal_ui/frontend/styles.py | 1 + sepal_ui/mapping/v_inspector.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/sepal_ui/frontend/styles.py b/sepal_ui/frontend/styles.py index 5640d606..f6399689 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);} """ diff --git a/sepal_ui/mapping/v_inspector.py b/sepal_ui/mapping/v_inspector.py index 6c1b2575..502357b0 100644 --- a/sepal_ui/mapping/v_inspector.py +++ b/sepal_ui/mapping/v_inspector.py @@ -3,6 +3,7 @@ from sepal_ui import sepalwidgets as sw from sepal_ui.scripts import utils as su +from sepal_ui import color as sc class VInspector(WidgetControl): @@ -30,7 +31,7 @@ def __init__(self, m, **kwargs): v_on="menu.on", color="text-color", outlined=True, - style_="padding: 0px; min-width: 0px; width: 30px; height: 30px;", + style_=f"padding: 0px; min-width: 0px; width: 30px; height: 30px; background: {sc.bg};", children=[icon], ) slot = {"name": "activator", "variable": "menu", "children": btn} From b98bb9c30d1c0c92579ac4d68e3afd984888b119 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 23 May 2022 11:15:43 +0000 Subject: [PATCH 15/43] fix: inspect ee_objects --- sepal_ui/mapping/__init__.py | 1 + sepal_ui/mapping/layer.py | 39 ++++++++++++++++++++++++++++++ sepal_ui/mapping/mapping.py | 26 +++++++++++--------- sepal_ui/mapping/v_inspector.py | 43 ++++++++++++++++++++++++++++++--- 4 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 sepal_ui/mapping/layer.py diff --git a/sepal_ui/mapping/__init__.py b/sepal_ui/mapping/__init__.py index b59185b2..a8570707 100644 --- a/sepal_ui/mapping/__init__.py +++ b/sepal_ui/mapping/__init__.py @@ -2,3 +2,4 @@ from .fullscreen_control import * from .draw_control import * from .v_inspector import * +from .layer import * diff --git a/sepal_ui/mapping/layer.py b/sepal_ui/mapping/layer.py new file mode 100644 index 00000000..75ab870e --- /dev/null +++ b/sepal_ui/mapping/layer.py @@ -0,0 +1,39 @@ +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) + + +class RasterLayer(TileLayer): + """ + Wrapper of the TileLayer class to add the raster as a member. + useful to get back the values for specific points in a v_inspector + + Args: + raster (np.array): the raster displayed on the map + """ + + raster = None + "(np.array): the raster displayed on the map" + + def __init__(self, raster, **kwargs): + + self.raster = raster + + super().__init__(**kwargs) diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index 04a8783f..bc072446 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -34,6 +34,7 @@ from sepal_ui.mapping.basemaps import xyz_to_leaflet from sepal_ui.mapping.draw_control import DrawControl from sepal_ui.mapping.v_inspector import VInspector +from sepal_ui.mapping.layer import EELayer __all__ = ["SepalMap"] @@ -541,14 +542,15 @@ def add_ee_Layer( name = "Layer " + str(layer_count + 1) # check the type of the ee object and raise an error if it's not recognized - if not any( - [ - isinstance(ee_object, ee.Image), - isinstance(ee_object, ee.ImageCollection), - isinstance(ee_object, ee.FeatureCollection), - isinstance(ee_object, ee.Feature), - isinstance(ee_object, ee.Geometry), - ] + if not isinstance( + ee_object, + ( + ee.Image, + ee.ImageCollection, + ee.FeatureCollection, + ee.Feature, + ee.Geometry, + ), ): err_str = "\n\nThe image argument in 'addLayer' function must be an instance of one of ee.Image, ee.Geometry, ee.Feature or ee.FeatureCollection." raise AttributeError(err_str) @@ -575,18 +577,20 @@ def add_ee_Layer( ) image = image_fill.blend(image_outline) + obj = features # use directly the ee object if Image elif isinstance(ee_object, ee.image.Image): - image = ee_object + image = obj = ee_object # use mosaicing if the ee_object is a ImageCollection elif isinstance(ee_object, ee.imagecollection.ImageCollection): - image = ee_object.mosaic() + image = obj = ee_object.mosaic() # create the colored image map_id_dict = ee.Image(image).getMapId(vis_params) - tile_layer = ipl.TileLayer( + tile_layer = EELayer( + ee_object=obj, url=map_id_dict["tile_fetcher"].url_format, attribution="Google Earth Engine", name=name, diff --git a/sepal_ui/mapping/v_inspector.py b/sepal_ui/mapping/v_inspector.py index 502357b0..982323a5 100644 --- a/sepal_ui/mapping/v_inspector.py +++ b/sepal_ui/mapping/v_inspector.py @@ -1,9 +1,11 @@ import ipyvuetify as v from ipyleaflet import WidgetControl +import ee from sepal_ui import sepalwidgets as sw from sepal_ui.scripts import utils as su from sepal_ui import color as sc +from sepal_ui.mapping.layer import EELayer class VInspector(WidgetControl): @@ -99,9 +101,15 @@ def read_data(self, **kwargs): layers = [lyr for lyr in self.m.layers if lyr.base is False] for lyr in layers: children.append(sw.Html(tag="h5", children=[lyr.name])) - children.append( - sw.Html(tag="p", children=["data reading method not yet ready"]) - ) + + if isinstance(lyr, EELayer): + data = self._from_eelayer(lyr.ee_object, latlon) + else: + data = {"info": "data reading method not yet ready"} + + 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 @@ -110,3 +118,32 @@ def read_data(self, **kwargs): self.m.default_style = {"cursor": "crosshair"} return + + @su.need_ee + def _from_eelayer(self, ee_obj, coords): + """extract the values of the ee_object for the considered point""" + + # create a gee point + lat, lng = coords + ee_point = ee.Geometry.Point(lng, lat) + + if isinstance(ee_obj, ee.FeatureCollection): + + # filter all the value to the point + pixel_values = ee_obj.filterBounds(ee_point).first().toDictionary() + + 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(), + ) + + else: + raise ValueError( + f'the layer object is a "{type(ee_obj)}" which is not accepted.' + ) + + return pixel_values.getInfo() From b834b679582c853a1b21e1f87057618f6df9e093 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 23 May 2022 13:11:30 +0000 Subject: [PATCH 16/43] fix: read GeoJSON data --- sepal_ui/mapping/v_inspector.py | 65 ++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/sepal_ui/mapping/v_inspector.py b/sepal_ui/mapping/v_inspector.py index 982323a5..8d4bb70f 100644 --- a/sepal_ui/mapping/v_inspector.py +++ b/sepal_ui/mapping/v_inspector.py @@ -1,6 +1,8 @@ import ipyvuetify as v -from ipyleaflet import WidgetControl +from ipyleaflet import WidgetControl, GeoJSON import ee +import geopandas as gpd +from shapely import geometry as sg from sepal_ui import sepalwidgets as sw from sepal_ui.scripts import utils as su @@ -104,6 +106,8 @@ def read_data(self, **kwargs): if isinstance(lyr, EELayer): data = self._from_eelayer(lyr.ee_object, latlon) + elif isinstance(lyr, GeoJSON): + data = self._from_geojson(lyr.data, latlon) else: data = {"info": "data reading method not yet ready"} @@ -121,7 +125,16 @@ def read_data(self, **kwargs): @su.need_ee def _from_eelayer(self, ee_obj, coords): - """extract the values of the ee_object for the considered point""" + """ + 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 (lat, lng). + + Return: + (dict): tke value associated to the bad/feature names + """ # create a gee point lat, lng = coords @@ -130,7 +143,16 @@ def _from_eelayer(self, ee_obj, coords): if isinstance(ee_obj, ee.FeatureCollection): # filter all the value to the point - pixel_values = ee_obj.filterBounds(ee_point).first().toDictionary() + 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)): @@ -139,11 +161,44 @@ def _from_eelayer(self, ee_obj, coords): 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.getInfo() + 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 (lat, lng). + + Return: + (dict): tke value associated to the feature names + """ + + # extract the coordinates as a point + lat, lng = coords + point = sg.Point(lng, lat) + + # filter the data to 1 point + gdf = gpd.GeoDataFrame.from_features(data) + gdf_filtered = gdf[gdf.contains(point)] + + # only display the columns name if empty + if len(gdf_filtered) == 0: + cols = list(set(["geometry", "style"]) ^ set(gdf.columns.to_list())) + pixel_values = {c: None for c in cols} + + # else print the values of the first element + else: + pixel_values = gdf_filtered.iloc[0].to_dict() + pixel_values.pop("geometry") + pixel_values.pop("style") + + return pixel_values From 47f945c5064cff119727821c102164f15007f59c Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 23 May 2022 15:17:19 +0000 Subject: [PATCH 17/43] fix: inspect rasters --- sepal_ui/mapping/mapping.py | 5 ++-- sepal_ui/mapping/v_inspector.py | 53 ++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index bc072446..880b1e5b 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -311,11 +311,12 @@ def add_raster( # 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") diff --git a/sepal_ui/mapping/v_inspector.py b/sepal_ui/mapping/v_inspector.py index 8d4bb70f..1737cc73 100644 --- a/sepal_ui/mapping/v_inspector.py +++ b/sepal_ui/mapping/v_inspector.py @@ -1,14 +1,23 @@ import ipyvuetify as v -from ipyleaflet import WidgetControl, GeoJSON +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 from sepal_ui import sepalwidgets as sw from sepal_ui.scripts import utils as su from sepal_ui import color as sc from sepal_ui.mapping.layer import EELayer +# 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 VInspector(WidgetControl): @@ -108,6 +117,8 @@ def read_data(self, **kwargs): data = self._from_eelayer(lyr.ee_object, latlon) elif isinstance(lyr, GeoJSON): data = self._from_geojson(lyr.data, latlon) + elif isinstance(lyr, LocalTileLayer): + data = self._from_raster(lyr.raster, latlon) else: data = {"info": "data reading method not yet ready"} @@ -202,3 +213,43 @@ def _from_geojson(self, data, coords): pixel_values.pop("style") return pixel_values + + 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 (lat, lng). + + Return: + (dict): tke value associated to the feature names + """ + + # extract the coordinates as a point + lat, lng = coords + point = sg.Point(lng, lat) + + # 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 udo as in GEE a mean reducer around 1px + # is it an overkill ? yes + if sg.box(*da.rio.bounds()).contains(point): + bounds = (lng - scale, lat - scale, lng + scale, lat + scale) + 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 = {f"band {i+1}": v for i, v in enumerate(means)} + + # if the point is out of the image display None + else: + pixel_values = {f"band {i+1}": None for i in range(da.rio.count)} + + return pixel_values From 3a79cec3913725dcf097e30ca97b8bc386e3abfe Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 23 May 2022 16:07:29 +0000 Subject: [PATCH 18/43] feat: add a MapBtn --- sepal_ui/frontend/styles.py | 8 +++++++ sepal_ui/mapping/__init__.py | 1 + sepal_ui/mapping/fullscreen_control.py | 19 ++++++--------- sepal_ui/mapping/layer.py | 19 --------------- sepal_ui/mapping/map_btn.py | 32 ++++++++++++++++++++++++++ sepal_ui/mapping/v_inspector.py | 12 ++-------- 6 files changed, 50 insertions(+), 41 deletions(-) create mode 100644 sepal_ui/mapping/map_btn.py diff --git a/sepal_ui/frontend/styles.py b/sepal_ui/frontend/styles.py index f6399689..661d37e6 100644 --- a/sepal_ui/frontend/styles.py +++ b/sepal_ui/frontend/styles.py @@ -173,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 a8570707..28950da3 100644 --- a/sepal_ui/mapping/__init__.py +++ b/sepal_ui/mapping/__init__.py @@ -3,3 +3,4 @@ from .draw_control import * from .v_inspector import * from .layer import * +from .map_btn import * diff --git a/sepal_ui/mapping/fullscreen_control.py b/sepal_ui/mapping/fullscreen_control.py index 29f65d72..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): @@ -17,7 +18,7 @@ class FullScreenControl(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 index 75ab870e..c3d08177 100644 --- a/sepal_ui/mapping/layer.py +++ b/sepal_ui/mapping/layer.py @@ -18,22 +18,3 @@ def __init__(self, ee_object, **kwargs): self.ee_object = ee_object super().__init__(**kwargs) - - -class RasterLayer(TileLayer): - """ - Wrapper of the TileLayer class to add the raster as a member. - useful to get back the values for specific points in a v_inspector - - Args: - raster (np.array): the raster displayed on the map - """ - - raster = None - "(np.array): the raster displayed on the map" - - def __init__(self, raster, **kwargs): - - self.raster = raster - - 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/v_inspector.py b/sepal_ui/mapping/v_inspector.py index 1737cc73..f125bf09 100644 --- a/sepal_ui/mapping/v_inspector.py +++ b/sepal_ui/mapping/v_inspector.py @@ -1,4 +1,3 @@ -import ipyvuetify as v from ipyleaflet import WidgetControl, GeoJSON, LocalTileLayer import ee import geopandas as gpd @@ -10,8 +9,8 @@ from sepal_ui import sepalwidgets as sw from sepal_ui.scripts import utils as su -from sepal_ui import color as sc from sepal_ui.mapping.layer import EELayer +from sepal_ui.mapping.map_btn import MapBtn # call x_array leaflet at least once # flake8 will complain as it's a pluggin (i.e. never called) @@ -39,14 +38,7 @@ def __init__(self, m, **kwargs): kwargs["position"] = kwargs.pop("position", "bottomright") # create a clickable btn - icon = sw.Icon(small=True, children=["mdi-cloud-download"]) - btn = v.Btn( - v_on="menu.on", - color="text-color", - outlined=True, - style_=f"padding: 0px; min-width: 0px; width: 30px; height: 30px; background: {sc.bg};", - children=[icon], - ) + btn = MapBtn(logo="fas fa-chart-bar", v_on="menu.on") slot = {"name": "activator", "variable": "menu", "children": btn} title = sw.CardTitle(children=[sw.Html(tag="h4", children=["Inspector"])]) self.text = sw.CardText(children=["select a point"]) From aadaa81471ab0e0d655f4fe26fc2e6d0c686d498 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 23 May 2022 16:22:20 +0000 Subject: [PATCH 19/43] fix: remove legacy dot on the map Fix #456 --- sepal_ui/mapping/mapping.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index 880b1e5b..bcd1a558 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -242,7 +242,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. @@ -257,7 +257,7 @@ def add_raster( 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. """ # force cast to Path @@ -304,7 +304,7 @@ 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, } From 4a152e9556b7a8755e5491c5ad74c86133bd0a1b Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 23 May 2022 16:39:25 +0000 Subject: [PATCH 20/43] fix: avoid the v_inspector to move down the map --- sepal_ui/mapping/v_inspector.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/sepal_ui/mapping/v_inspector.py b/sepal_ui/mapping/v_inspector.py index f125bf09..dd38952b 100644 --- a/sepal_ui/mapping/v_inspector.py +++ b/sepal_ui/mapping/v_inspector.py @@ -42,12 +42,11 @@ def __init__(self, m, **kwargs): slot = {"name": "activator", "variable": "menu", "children": btn} title = sw.CardTitle(children=[sw.Html(tag="h4", children=["Inspector"])]) self.text = sw.CardText(children=["select a point"]) - self.card = sw.Card( - children=[title, self.text], min_width="400px", min_height="200px" - ) + self.card = sw.Card(children=[title, self.text], min_width="400px") # assempble everything in a menu self.menu = sw.Menu( + max_height="40vh", v_model=False, value=False, close_on_click=False, @@ -77,7 +76,6 @@ def toggle_cursor(self, change): return - @su.switch("loading", on_widgets=["card"]) def read_data(self, **kwargs): """ Read the data when the map is clicked with the vinspector activated @@ -88,7 +86,9 @@ def read_data(self, **kwargs): if not (is_click and is_active): return - # set the curosr to loading mode + # set the loading mode. Cannot be done as a decorator to avoid + # flickering while moving the cursor on the map + self.card.loading = True self.m.default_style = {"cursor": "wait"} # init the text children @@ -122,8 +122,16 @@ def read_data(self, **kwargs): self.text.children = children # set back the cursor to crosshair + self.card.loading = 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 From e9832685f5dad91896012ce7b3af2c5dbe918ed9 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 23 May 2022 17:09:15 +0000 Subject: [PATCH 21/43] refactor: reorder the coordinates --- sepal_ui/mapping/v_inspector.py | 35 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/sepal_ui/mapping/v_inspector.py b/sepal_ui/mapping/v_inspector.py index dd38952b..232d5a83 100644 --- a/sepal_ui/mapping/v_inspector.py +++ b/sepal_ui/mapping/v_inspector.py @@ -94,10 +94,14 @@ def read_data(self, **kwargs): # init the text children children = [] + # get the coordinates as (x, y) + coords = [c for c in reversed(kwargs.get("coordinates"))] + # write the coordinates - latlon = kwargs.get("coordinates") - children.append(sw.Html(tag="h4", children=["Coordinates"])) - children.append(sw.Html(tag="p", children=[str(latlon)])) + children.append( + sw.Html(tag="h4", children=["Coordinates (longitude, latitude)"]) + ) + children.append(sw.Html(tag="p", children=[str(coords)])) # write the layers data children.append(sw.Html(tag="h4", children=["Layers"])) @@ -106,11 +110,11 @@ def read_data(self, **kwargs): children.append(sw.Html(tag="h5", children=[lyr.name])) if isinstance(lyr, EELayer): - data = self._from_eelayer(lyr.ee_object, latlon) + data = self._from_eelayer(lyr.ee_object, coords) elif isinstance(lyr, GeoJSON): - data = self._from_geojson(lyr.data, latlon) + data = self._from_geojson(lyr.data, coords) elif isinstance(lyr, LocalTileLayer): - data = self._from_raster(lyr.raster, latlon) + data = self._from_raster(lyr.raster, coords) else: data = {"info": "data reading method not yet ready"} @@ -141,15 +145,14 @@ def _from_eelayer(self, ee_obj, coords): Args: ee_obj (ee.object): the ee object to reduce to a single point - coords (tuple): the coordinates of the point (lat, lng). + coords (tuple): the coordinates of the point (lng, lat). Return: (dict): tke value associated to the bad/feature names """ # create a gee point - lat, lng = coords - ee_point = ee.Geometry.Point(lng, lat) + ee_point = ee.Geometry.Point(*coords) if isinstance(ee_obj, ee.FeatureCollection): @@ -187,15 +190,14 @@ def _from_geojson(self, data, coords): Args: data (GeoJSON): the shape to reduce to a single point - coords (tuple): the coordinates of the point (lat, lng). + coords (tuple): the coordinates of the point (lng, lat). Return: (dict): tke value associated to the feature names """ - # extract the coordinates as a point - lat, lng = coords - point = sg.Point(lng, lat) + # extract the coordinates as a poin + point = sg.Point(*coords) # filter the data to 1 point gdf = gpd.GeoDataFrame.from_features(data) @@ -220,15 +222,14 @@ def _from_raster(self, raster, coords): Args: raster (str): the path to the image to reduce to a single point - coords (tuple): the coordinates of the point (lat, lng). + coords (tuple): the coordinates of the point (lng, lat). Return: (dict): tke value associated to the feature names """ # extract the coordinates as a point - lat, lng = coords - point = sg.Point(lng, lat) + point = sg.Point(*coords) # extract the pixel size in degrees (equatorial appoximation) scale = self.m.get_scale() * 0.00001 @@ -242,7 +243,7 @@ def _from_raster(self, raster, coords): # sample is not available for da so I udo as in GEE a mean reducer around 1px # is it an overkill ? yes if sg.box(*da.rio.bounds()).contains(point): - bounds = (lng - scale, lat - scale, lng + scale, lat + scale) + 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() From a78f7ec6aee386a27e9d9f33394cc21207a6cd12 Mon Sep 17 00:00:00 2001 From: dfguerrerom Date: Tue, 24 May 2022 09:19:18 +0000 Subject: [PATCH 22/43] refactor: some line breaks and removed a pair of condionals --- sepal_ui/mapping/mapping.py | 40 ++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index bcd1a558..57938d8f 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -51,10 +51,14 @@ class SepalMap(ipl.Map): """ - 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. + 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. @@ -275,8 +279,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 @@ -438,8 +444,10 @@ def add_ee_Layer( viz_name=False, ): """ - 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. + 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 @@ -553,8 +561,10 @@ def add_ee_Layer( ee.Geometry, ), ): - err_str = "\n\nThe image argument in 'addLayer' function must be an instance of one of ee.Image, ee.Geometry, ee.Feature or ee.FeatureCollection." - raise AttributeError(err_str) + 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( @@ -682,7 +692,8 @@ 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 = {} @@ -691,7 +702,8 @@ def get_viz_params(image): def remove_layer(self, key): """ - 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 + 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 @@ -722,8 +734,8 @@ def remove_all(self, base=False): """ # filter out the basemaps if base == False all_layers = (tl for tl in self.layers) - all_layers_no_basmaps = (tl for tl in self.layers if tl.base is False) - gen = all_layers if base is True else all_layers_no_basmaps + all_layers_no_basmaps = (tl for tl in self.layers if not tl.base) + gen = all_layers if base else all_layers_no_basmaps # remove them using the built generator [self.remove_layer(layer) for layer in gen] From a1b2bd10d2621b302ba978de33363e4ea840059d Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 25 May 2022 14:11:33 +0000 Subject: [PATCH 23/43] test: geojson_to_ee --- .../source/modules/sepal_ui.scripts.utils.rst | 17 +-------- sepal_ui/scripts/utils.py | 4 +- tests/test_utils.py | 37 +++++++++++++++++++ 3 files changed, 41 insertions(+), 17 deletions(-) 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/scripts/utils.py b/sepal_ui/scripts/utils.py index 09b0b529..0bf75e5c 100644 --- a/sepal_ui/scripts/utils.py +++ b/sepal_ui/scripts/utils.py @@ -579,7 +579,7 @@ def set_type(color): @versionadded(version="2.8.0") def geojson_to_ee(geo_json, geodesic=False, encoding="utf-8"): """ - Transform a geojson object into a featureCollection + 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__ `__. @@ -617,6 +617,6 @@ def geojson_to_ee(geo_json, geodesic=False, encoding="utf-8"): # some error handling because we are fancy else: - raise Exception("Could not convert the geojson to ee.Geometry()") + raise ValueError("Could not convert the geojson to ee.Geometry()") 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 From c145ba4689295bf6cbad0e0cbe2785cfe4843d9c Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 25 May 2022 17:25:16 +0000 Subject: [PATCH 24/43] test: basemaps --- .../modules/sepal_ui.mapping.basemaps.rst | 16 ++++++ docs/source/modules/sepal_ui.mapping.rst | 8 +++ sepal_ui/mapping/basemaps.py | 56 ++++++------------- tests/test_basemaps.py | 18 ++++++ 4 files changed, 59 insertions(+), 39 deletions(-) create mode 100644 docs/source/modules/sepal_ui.mapping.basemaps.rst create mode 100644 tests/test_basemaps.py 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..6af39f6a --- /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.get_xyz_dict + +.. autofunction:: sepal_ui.mapping.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..c4242562 100644 --- a/docs/source/modules/sepal_ui.mapping.rst +++ b/docs/source/modules/sepal_ui.mapping.rst @@ -3,6 +3,14 @@ sepal\_ui.mapping .. automodule:: sepal_ui.mapping +.. rubric:: Modules + +.. autosummary:: + :toctree: + :recursive: + + sepal_ui.mapping.basemaps + .. rubric:: Classes .. autosummary:: diff --git a/sepal_ui/mapping/basemaps.py b/sepal_ui/mapping/basemaps.py index 574e8081..b85fbf15 100644 --- a/sepal_ui/mapping/basemaps.py +++ b/sepal_ui/mapping/basemaps.py @@ -1,6 +1,6 @@ -import collections from ipyleaflet import TileLayer import xyzservices.providers as xyz +from xyzservices import TileProvider xyz_tiles = { @@ -33,10 +33,9 @@ "(dict): Custom XYZ tile services." -def get_xyz_dict(free_only=True): +def get_xyz_dict(free_only=True, _collection=xyz, _output={}): """ Returns a dictionary of xyz services. - Adapted from https://github.com/giswqs/geemap Args: free_only (bool, optional): Whether to return only free xyz tile services that do not require an access token. Defaults to True. @@ -45,40 +44,20 @@ def get_xyz_dict(free_only=True): dict: A dictionary of xyz services. """ - xyz_dict = {} - for item in xyz.values(): - try: - name = item["name"] - tile = eval("xyz." + name) - if eval("xyz." + name + ".requires_token()"): - if free_only: - pass - else: - xyz_dict[name] = tile - else: - xyz_dict[name] = tile - - except Exception: - for sub_item in item: - name = item[sub_item]["name"] - tile = eval("xyz." + name) - if eval("xyz." + name + ".requires_token()"): - if free_only: - pass - else: - xyz_dict[name] = tile - else: - xyz_dict[name] = tile + 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) - xyz_dict = collections.OrderedDict(sorted(xyz_dict.items())) - - return xyz_dict + return _output def xyz_to_leaflet(): """ Convert all available xyz tile services to ipyleaflet tile layers. - adapted from https://github.com/giswqs/geemap + Adapted from https://github.com/giswqs/geemap Returns: dict: A dictionary of ipyleaflet tile layers. @@ -93,14 +72,13 @@ def xyz_to_leaflet(): url=url, name=name, attribution=attribution, max_zoom=22, base=True ) - xyz_dict = get_xyz_dict() - for item in xyz_dict: - name = xyz_dict[item].name - url = xyz_dict[item].build_url() - attribution = xyz_dict[item].attribution - max_zoom = xyz_dict[item].pop("max_zoom", 22) - leaflet_dict[name] = TileLayer( - url=url, name=name, max_zoom=max_zoom, attribution=attribution, 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 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 From 6c42f82555fa4a5a38e57e2ff9d474e7201bbb72 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 25 May 2022 19:03:24 +0000 Subject: [PATCH 25/43] test: DrawControl --- .../modules/sepal_ui.mapping.DrawControl.rst | 22 ++++++++++ docs/source/modules/sepal_ui.mapping.rst | 3 +- sepal_ui/mapping/draw_control.py | 3 +- tests/test_DrawControl.py | 42 +++++++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 docs/source/modules/sepal_ui.mapping.DrawControl.rst create mode 100644 tests/test_DrawControl.py 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.rst b/docs/source/modules/sepal_ui.mapping.rst index c4242562..1db06d54 100644 --- a/docs/source/modules/sepal_ui.mapping.rst +++ b/docs/source/modules/sepal_ui.mapping.rst @@ -17,4 +17,5 @@ sepal\_ui.mapping :toctree: sepal_ui.mapping.SepalMap - sepal_ui.mapping.FullScreenControl \ No newline at end of file + sepal_ui.mapping.FullScreenControl + sepal_ui.mapping.DrawControl \ No newline at end of file diff --git a/sepal_ui/mapping/draw_control.py b/sepal_ui/mapping/draw_control.py index 5124760a..31f44b86 100644 --- a/sepal_ui/mapping/draw_control.py +++ b/sepal_ui/mapping/draw_control.py @@ -8,7 +8,8 @@ class DrawControl(DrawControl): A custom DrawingControl object to handle edition of features Args: - kwargs (optional): any available arguments from a ipyleaflet DrawingControl + m (ipyleaflet.Map): the map on which he drawControl is displayed + kwargs (optional): any available arguments from a ipyleaflet.DrawingControl """ m = None 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 From 5208cb873f88c6b5c18d15d331d15bdf16aa5016 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 25 May 2022 19:18:52 +0000 Subject: [PATCH 26/43] test: FullScreenControl --- .../sepal_ui.mapping.FullScreenControl.rst | 49 ++++--------------- tests/test_FullScreenControl.py | 10 ++-- 2 files changed, 15 insertions(+), 44 deletions(-) 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/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 From e720acee221aeeac8a5bad9821825f75899de52a Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 25 May 2022 19:33:09 +0000 Subject: [PATCH 27/43] test: EELayer --- .../source/modules/sepal_ui.mapping.Layer.rst | 10 ++++++++++ docs/source/modules/sepal_ui.mapping.rst | 3 ++- tests/test_EELayer.py | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 docs/source/modules/sepal_ui.mapping.Layer.rst create mode 100644 tests/test_EELayer.py 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.rst b/docs/source/modules/sepal_ui.mapping.rst index 1db06d54..8d85332e 100644 --- a/docs/source/modules/sepal_ui.mapping.rst +++ b/docs/source/modules/sepal_ui.mapping.rst @@ -18,4 +18,5 @@ sepal\_ui.mapping sepal_ui.mapping.SepalMap sepal_ui.mapping.FullScreenControl - sepal_ui.mapping.DrawControl \ No newline at end of file + sepal_ui.mapping.DrawControl + sepal_ui.mapping.Layer \ No newline at end of file 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 From f80568622aa59d084edb711fd003c6ca6e899619 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Wed, 25 May 2022 19:52:32 +0000 Subject: [PATCH 28/43] test: MapBtn --- docs/source/modules/sepal_ui.mapping.MapBtn.rst | 11 +++++++++++ docs/source/modules/sepal_ui.mapping.rst | 3 ++- tests/test_MapBtn.py | 11 +++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 docs/source/modules/sepal_ui.mapping.MapBtn.rst create mode 100644 tests/test_MapBtn.py 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.rst b/docs/source/modules/sepal_ui.mapping.rst index 8d85332e..4daa24ed 100644 --- a/docs/source/modules/sepal_ui.mapping.rst +++ b/docs/source/modules/sepal_ui.mapping.rst @@ -19,4 +19,5 @@ sepal\_ui.mapping sepal_ui.mapping.SepalMap sepal_ui.mapping.FullScreenControl sepal_ui.mapping.DrawControl - sepal_ui.mapping.Layer \ No newline at end of file + sepal_ui.mapping.EELayer + sepal_ui.mapping.MapBtn \ No newline at end of file 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 From 53843c3238a292509e63af7eefcb06fe71885d50 Mon Sep 17 00:00:00 2001 From: ingdanielguerrero Date: Thu, 26 May 2022 08:05:24 +0000 Subject: [PATCH 29/43] build: add python-box to requirements --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 46ee09f9..2b6b67a8 100644 --- a/setup.py +++ b/setup.py @@ -50,6 +50,7 @@ def run(self): "xyzservices", "planet", "pyyaml", + "python-box", ], "extras_require": { "dev": [ From cc2c423ec2271fceb183a204ec7c87ea8858f9b2 Mon Sep 17 00:00:00 2001 From: ingdanielguerrero Date: Thu, 26 May 2022 10:00:47 +0000 Subject: [PATCH 30/43] feat: make wheel scroll default param --- sepal_ui/mapping/mapping.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index 57938d8f..ba813223 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -91,6 +91,7 @@ def __init__(self, basemaps=[], dc=False, vinspector=False, gee=True, **kwargs): kwargs["basemap"] = {} kwargs["zoom_control"] = False kwargs["attribution_control"] = False + kwargs["scroll_wheel_zoom"] = True # Init the map super().__init__(**kwargs) From 1cc32c1812803ddf5351df0b81601ba25bfecf78 Mon Sep 17 00:00:00 2001 From: ingdanielguerrero Date: Thu, 26 May 2022 10:26:44 +0000 Subject: [PATCH 31/43] feat: return basemap box as default object from basemaps module --- sepal_ui/mapping/basemaps.py | 13 +++++++++++++ sepal_ui/mapping/mapping.py | 9 +-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/sepal_ui/mapping/basemaps.py b/sepal_ui/mapping/basemaps.py index b85fbf15..53bc8f48 100644 --- a/sepal_ui/mapping/basemaps.py +++ b/sepal_ui/mapping/basemaps.py @@ -1,6 +1,15 @@ 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 = { @@ -82,3 +91,7 @@ def xyz_to_leaflet(): ) return leaflet_dict + + +basemaps = BasemapBox(xyz_to_leaflet(), frozen_box=True) +"(Box.box): the basemaps list as a box" diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index ba813223..eadb63bc 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -24,18 +24,16 @@ import ipyvuetify as v import ipyleaflet as ipl import ee -from box import Box 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.basemaps import xyz_to_leaflet from sepal_ui.mapping.draw_control import DrawControl from sepal_ui.mapping.v_inspector import VInspector from sepal_ui.mapping.layer import EELayer - +from sepal_ui.mapping.basemaps import basemaps __all__ = ["SepalMap"] @@ -44,10 +42,6 @@ # We don't want to ignore testing F401 xarray_leaflet -# init the basemaps -basemaps = Box(xyz_to_leaflet(), frozen_box=True) -"(Box.box): the basemaps list as a box" - class SepalMap(ipl.Map): """ @@ -620,7 +614,6 @@ 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 """ From 487f0624d48c17ae7b7e7f5d7bea37ffeda27850 Mon Sep 17 00:00:00 2001 From: ingdanielguerrero Date: Thu, 26 May 2022 13:37:56 +0000 Subject: [PATCH 32/43] refactor: rename value inspector module and add a closing icon --- sepal_ui/mapping/__init__.py | 2 +- sepal_ui/mapping/mapping.py | 4 +- .../{v_inspector.py => value_inspector.py} | 41 ++++++++++++------- 3 files changed, 30 insertions(+), 17 deletions(-) rename sepal_ui/mapping/{v_inspector.py => value_inspector.py} (87%) diff --git a/sepal_ui/mapping/__init__.py b/sepal_ui/mapping/__init__.py index 28950da3..3347ac52 100644 --- a/sepal_ui/mapping/__init__.py +++ b/sepal_ui/mapping/__init__.py @@ -1,6 +1,6 @@ from .mapping import * from .fullscreen_control import * from .draw_control import * -from .v_inspector import * +from .value_inspector import * from .layer import * from .map_btn import * diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index eadb63bc..00e51604 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -31,7 +31,7 @@ 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.v_inspector import VInspector +from sepal_ui.mapping.value_inspector import ValueInspector from sepal_ui.mapping.layer import EELayer from sepal_ui.mapping.basemaps import basemaps @@ -113,7 +113,7 @@ def __init__(self, basemaps=[], dc=False, vinspector=False, gee=True, **kwargs): not dc or self.add_control(self.dc) # specific v_inspector - self.v_inspector = VInspector(self) + self.v_inspector = ValueInspector(self) not vinspector or self.add_control(self.v_inspector) @deprecated(version="2.8.0", reason="the local_layer stored list has been dropped") diff --git a/sepal_ui/mapping/v_inspector.py b/sepal_ui/mapping/value_inspector.py similarity index 87% rename from sepal_ui/mapping/v_inspector.py rename to sepal_ui/mapping/value_inspector.py index 232d5a83..bcb97149 100644 --- a/sepal_ui/mapping/v_inspector.py +++ b/sepal_ui/mapping/value_inspector.py @@ -18,7 +18,7 @@ xarray_leaflet -class VInspector(WidgetControl): +class ValueInspector(WidgetControl): m = None "(ipyleaflet.Map) the map on which he vinspector is displayed to interact with it's layers" @@ -40,7 +40,19 @@ def __init__(self, m, **kwargs): # create a clickable btn btn = MapBtn(logo="fas fa-chart-bar", v_on="menu.on") slot = {"name": "activator", "variable": "menu", "children": btn} - title = sw.CardTitle(children=[sw.Html(tag="h4", children=["Inspector"])]) + self.close_card_btn = sw.Icon(children=["fa fa-close"], small=True) + title = sw.CardTitle( + children=[ + sw.Html( + tag="h4", + children=[ + "Inspector", + ], + ), + sw.Spacer(), + self.close_card_btn, + ] + ) self.text = sw.CardText(children=["select a point"]) self.card = sw.Card(children=[title, self.text], min_width="400px") @@ -65,11 +77,15 @@ def __init__(self, m, **kwargs): # add js behaviour self.menu.observe(self.toggle_cursor, "v_model") self.m.on_interaction(self.read_data) + self.close_card_btn.on_event("click", self.close_card) + + def close_card(self, widget, event, data): + """Manually close card (menu)""" + self.menu.v_model = False def toggle_cursor(self, change): - """ - Toggle the cursor displa on the map to notify to the user that the inspector mode is activated - """ + """Toggle the cursor displa 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] @@ -95,7 +111,7 @@ def read_data(self, **kwargs): children = [] # get the coordinates as (x, y) - coords = [c for c in reversed(kwargs.get("coordinates"))] + coords = [round(c, 3) for c in reversed(kwargs.get("coordinates"))] # write the coordinates children.append( @@ -105,7 +121,7 @@ def read_data(self, **kwargs): # write the layers data children.append(sw.Html(tag="h4", children=["Layers"])) - layers = [lyr for lyr in self.m.layers if lyr.base is False] + 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])) @@ -202,19 +218,16 @@ def _from_geojson(self, data, 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 = list(set(["geometry", "style"]) ^ set(gdf.columns.to_list())) - pixel_values = {c: None for c in cols} + cols = list(set(skip_cols) ^ set(gdf.columns.to_list())) + return {c: None for c in cols} # else print the values of the first element else: - pixel_values = gdf_filtered.iloc[0].to_dict() - pixel_values.pop("geometry") - pixel_values.pop("style") - - return pixel_values + return gdf_filtered.iloc[0, ~gdf.columns.isin(skip_cols)].to_dict() def _from_raster(self, raster, coords): """ From 465249b9163d50cffb7fc804b1aa548ba21a175a Mon Sep 17 00:00:00 2001 From: 12rambau Date: Thu, 26 May 2022 14:06:29 +0000 Subject: [PATCH 33/43] fix: prepare refactoring of ValueInspector --- .../sepal_ui.mapping.ValueInspector.rst | 23 +++ docs/source/modules/sepal_ui.mapping.rst | 3 +- sepal_ui/mapping/__init__.py | 2 +- sepal_ui/mapping/mapping.py | 4 +- .../{v_inspector.py => value_inspector.py} | 28 ++- tests/test_ValueInspector.py | 173 ++++++++++++++++++ 6 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 docs/source/modules/sepal_ui.mapping.ValueInspector.rst rename sepal_ui/mapping/{v_inspector.py => value_inspector.py} (91%) create mode 100644 tests/test_ValueInspector.py 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..0832fae3 --- /dev/null +++ b/docs/source/modules/sepal_ui.mapping.ValueInspector.rst @@ -0,0 +1,23 @@ +sepal\_ui.mapping.ValueInspector +================================ + +.. autoclass:: sepal_ui.mapping.FullScreenControl + + .. rubric:: Attributes + + .. autosummary:: + + ~FullScreenControl.ICONS + ~FullScreenControl.METHODS + ~FullScreenControl.zoomed + ~FullScreenControl.w_btn + ~FullScreenControl.template + + .. rubric:: Methods + + .. autosummary:: + :nosignatures: + + ~FullScreenControl.toggle_fullscreen + +.. automethod:: sepal_ui.mapping.FullScreenControl.toggle_fullscreen diff --git a/docs/source/modules/sepal_ui.mapping.rst b/docs/source/modules/sepal_ui.mapping.rst index 4daa24ed..ffed4246 100644 --- a/docs/source/modules/sepal_ui.mapping.rst +++ b/docs/source/modules/sepal_ui.mapping.rst @@ -20,4 +20,5 @@ sepal\_ui.mapping sepal_ui.mapping.FullScreenControl sepal_ui.mapping.DrawControl sepal_ui.mapping.EELayer - sepal_ui.mapping.MapBtn \ No newline at end of file + sepal_ui.mapping.MapBtn + sepal_ui.mapping.ValueInspector \ No newline at end of file diff --git a/sepal_ui/mapping/__init__.py b/sepal_ui/mapping/__init__.py index 28950da3..3347ac52 100644 --- a/sepal_ui/mapping/__init__.py +++ b/sepal_ui/mapping/__init__.py @@ -1,6 +1,6 @@ from .mapping import * from .fullscreen_control import * from .draw_control import * -from .v_inspector import * +from .value_inspector import * from .layer import * from .map_btn import * diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/mapping.py index 57938d8f..2e84e44f 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/mapping.py @@ -33,7 +33,7 @@ from sepal_ui.message import ms from sepal_ui.mapping.basemaps import xyz_to_leaflet from sepal_ui.mapping.draw_control import DrawControl -from sepal_ui.mapping.v_inspector import VInspector +from sepal_ui.mapping.value_inspector import ValueInspector from sepal_ui.mapping.layer import EELayer @@ -118,7 +118,7 @@ def __init__(self, basemaps=[], dc=False, vinspector=False, gee=True, **kwargs): not dc or self.add_control(self.dc) # specific v_inspector - self.v_inspector = VInspector(self) + self.v_inspector = ValueInspector(self) not vinspector or self.add_control(self.v_inspector) @deprecated(version="2.8.0", reason="the local_layer stored list has been dropped") diff --git a/sepal_ui/mapping/v_inspector.py b/sepal_ui/mapping/value_inspector.py similarity index 91% rename from sepal_ui/mapping/v_inspector.py rename to sepal_ui/mapping/value_inspector.py index 232d5a83..40655695 100644 --- a/sepal_ui/mapping/v_inspector.py +++ b/sepal_ui/mapping/value_inspector.py @@ -6,11 +6,14 @@ 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 # call x_array leaflet at least once # flake8 will complain as it's a pluggin (i.e. never called) @@ -18,7 +21,7 @@ xarray_leaflet -class VInspector(WidgetControl): +class ValueInspector(WidgetControl): m = None "(ipyleaflet.Map) the map on which he vinspector is displayed to interact with it's layers" @@ -37,21 +40,34 @@ def __init__(self, m, **kwargs): # 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-chart-bar", v_on="menu.on") slot = {"name": "activator", "variable": "menu", "children": btn} title = sw.CardTitle(children=[sw.Html(tag="h4", children=["Inspector"])]) self.text = sw.CardText(children=["select a point"]) - self.card = sw.Card(children=[title, self.text], min_width="400px") + self.card = sw.Card( + color=color.menu, + max_height="40vh", + children=[title, self.text], + min_width="400px", + style_="overflow: auto; border-radius: 0 0 0 0;", + ) # assempble everything in a menu self.menu = sw.Menu( - max_height="40vh", v_model=False, value=False, close_on_click=False, close_on_content_click=False, - children=[self.card], + children=[self.w_loading, self.card], v_slots=[slot], offset_x=True, top="bottom" in kwargs["position"], @@ -88,7 +104,7 @@ def read_data(self, **kwargs): # set the loading mode. Cannot be done as a decorator to avoid # flickering while moving the cursor on the map - self.card.loading = True + self.w_loading.indeterminate = True self.m.default_style = {"cursor": "wait"} # init the text children @@ -126,7 +142,7 @@ def read_data(self, **kwargs): self.text.children = children # set back the cursor to crosshair - self.card.loading = False + self.w_loading.indeterminate = False self.m.default_style = {"cursor": "crosshair"} # one last flicker to replace the menu next to the btn diff --git a/tests/test_ValueInspector.py b/tests/test_ValueInspector.py new file mode 100644 index 00000000..c6d0e85e --- /dev/null +++ b/tests/test_ValueInspector.py @@ -0,0 +1,173 @@ +import pytest +from pathlib import Path +import shutil +import json +from configparser import ConfigParser + +from sepal_ui import config_file +from sepal_ui.translator import Translator + + +class TestTranslator: + def test_init(self, translation_folder, tmp_config_file): + + # assert that the test key exist in fr + translator = Translator(translation_folder, "fr") + assert translator.test_key == "Clef de test" + + # assert that the the code work if the path is a str + translator = Translator(str(translation_folder), "fr") + assert translator.test_key == "Clef de test" + + # assert that the test does not exist in es and we fallback to en + translator = Translator(translation_folder, "es") + assert translator.test_key == "Test key" + + # assert that using a non existing lang lead to fallback to english + translator = Translator(translation_folder, "it") + assert translator.test_key == "Test key" + + # assert that if nothing is set it will use the confi_file (fr-FR) + translator = Translator(translation_folder) + assert translator.test_key == "Clef de test" + + return + + def test_search_key(self): + + # assert that having a wrong key in the json will raise an error + key = "toto" + d = {"a": {"toto": "b"}, "c": "d"} + + with pytest.raises(Exception): + Translator.search_key(d, key) + + return + + def test_sanitize(self): + + # a test dict with many embeded numbered list + # but also an already existing list + test = { + "a": {"0": "b", "1": "c"}, + "d": {"e": {"0": "f", "1": "g"}, "h": "i"}, + "j": ["k", "l"], + } + + # the sanitize version of this + result = { + "a": ["b", "c"], + "d": {"e": ["f", "g"], "h": "i"}, + "j": ["k", "l"], + } + + assert Translator.sanitize(test) == result + + return + + def test_delete_empty(self): + + test = {"a": "", "b": 1, "c": {"d": ""}, "e": {"f": "", "g": 2}} + result = {"b": 1, "c": {}, "e": {"g": 2}} + + assert Translator.delete_empty(test) == result + + def test_missing_keys(self, translation_folder): + + # check that all keys are in the fr dict + translator = Translator(translation_folder, "fr") + assert translator.missing_keys() == "All messages are translated" + + # check that 1 key is missing + translator = Translator(translation_folder, "es") + assert translator.missing_keys() == "root['test_key']" + + return + + def test_find_target(self, translation_folder): + + # test grid + test_grid = { + "en": ("en", "en"), + "en-US": ("en-US", "en"), + "fr-FR": ("fr-FR", "fr-FR"), + "fr-CA": ("fr-CA", "fr"), + "fr": ("fr", "fr"), + "da": ("da", None), + } + + # loop in the test grid to check multiple language combinations + for k, v in test_grid.items(): + assert Translator.find_target(translation_folder, k) == v + + return + + def test_available_locales(self, translation_folder): + + # expected grid + res = ["es", "fr", "fr-FR", "en"] + + # create the translator + # -en- to -en- + translator = Translator(translation_folder) + + for locale in res: + assert locale in translator.available_locales() + + return + + @pytest.fixture(scope="class") + def translation_folder(self): + """ + Generate a fully qualified translation folder with limited keys in en, fr and es. + Cannot use the temfile lib as we need the directory to appear in the tree + """ + + # set up the appropriate keys for each language + keys = { + "en": {"a_key": "A key", "test_key": "Test key"}, + "fr": {"a_key": "Une clef", "test_key": "Clef de test"}, + "fr-FR": {"a_key": "Une clef", "test_key": "Clef de test"}, + "es": {"a_key": "Una llave"}, + } + + # generate the tmp_dir in the test directory + tmp_dir = Path(__file__).parent / "data" / "messages" + tmp_dir.mkdir(exist_ok=True, parents=True) + + # create the translation files + for lan, d in keys.items(): + folder = tmp_dir / lan + folder.mkdir() + (folder / "locale.json").write_text(json.dumps(d, indent=2)) + + yield tmp_dir + + # flush everything + shutil.rmtree(tmp_dir) + + return + + @pytest.fixture(scope="function") + def tmp_config_file(self): + """ + Erase any existing config file and replace it with one specifically + design for thesting the translation + """ + + # erase anything that exists + if config_file.is_file(): + config_file.unlink() + + # create a new file + config = ConfigParser() + config.add_section("sepal-ui") + config.set("sepal-ui", "locale", "fr-FR") + config.write(config_file.open("w")) + + yield 1 + + # flush it + config_file.unlink() + + return From eb9002e4f8cc327b6227944c18171e9819476e9e Mon Sep 17 00:00:00 2001 From: 12rambau Date: Fri, 27 May 2022 11:11:28 +0000 Subject: [PATCH 34/43] test: valueInspector --- .../sepal_ui.mapping.ValueInspector.rst | 20 +- sepal_ui/mapping/value_inspector.py | 69 +++--- tests/test_ValueInspector.py | 233 +++++++++--------- 3 files changed, 159 insertions(+), 163 deletions(-) diff --git a/docs/source/modules/sepal_ui.mapping.ValueInspector.rst b/docs/source/modules/sepal_ui.mapping.ValueInspector.rst index 0832fae3..c37faa99 100644 --- a/docs/source/modules/sepal_ui.mapping.ValueInspector.rst +++ b/docs/source/modules/sepal_ui.mapping.ValueInspector.rst @@ -1,23 +1,25 @@ sepal\_ui.mapping.ValueInspector ================================ -.. autoclass:: sepal_ui.mapping.FullScreenControl +.. autoclass:: sepal_ui.mapping.ValueInspector .. rubric:: Attributes .. autosummary:: - ~FullScreenControl.ICONS - ~FullScreenControl.METHODS - ~FullScreenControl.zoomed - ~FullScreenControl.w_btn - ~FullScreenControl.template - + ~ValueInspector.m + ~ValueInspector.w_loading + ~ValueInspector.menu + ~ValueInspector.text + .. rubric:: Methods .. autosummary:: :nosignatures: - ~FullScreenControl.toggle_fullscreen + ~ValueInspector.toggle_cursor + ~ValueInspector.read_data -.. automethod:: sepal_ui.mapping.FullScreenControl.toggle_fullscreen +.. automethod:: sepal_ui.mapping.ValueInspector.toggle_cursor + +.. automethod:: sepal_ui.mapping.ValueInspector.read_data diff --git a/sepal_ui/mapping/value_inspector.py b/sepal_ui/mapping/value_inspector.py index 28b20477..68bb55ed 100644 --- a/sepal_ui/mapping/value_inspector.py +++ b/sepal_ui/mapping/value_inspector.py @@ -22,15 +22,24 @@ 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" - menu = None + w_loading = None + "(vuetify.ProgressLinear): the progress bar on top of the Card" - card = None + 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): @@ -49,28 +58,19 @@ def __init__(self, m, **kwargs): ) # create a clickable btn - btn = MapBtn(logo="fas fa-chart-bar", v_on="menu.on") + btn = MapBtn(logo="fas fa-crosshairs", v_on="menu.on") slot = {"name": "activator", "variable": "menu", "children": btn} - self.close_card_btn = sw.Icon(children=["fa fa-close"], small=True) - title = sw.CardTitle( - children=[ - sw.Html( - tag="h4", - children=[ - "Inspector", - ], - ), - sw.Spacer(), - self.close_card_btn, - ] - ) + close_btn = sw.Icon(children=["fas fa-times"], small=True) + title = sw.Html(tag="h4", children=["Inspector"]) + card_title = sw.CardTitle(children=[title, sw.Spacer(), close_btn]) self.text = sw.CardText(children=["select a point"]) - self.card = sw.Card( + card = sw.Card( + tile=True, color=color.menu, max_height="40vh", - children=[title, self.text], + children=[card_title, self.text], min_width="400px", - style_="overflow: auto; border-radius: 0 0 0 0;", + style_="overflow: auto", ) # assempble everything in a menu @@ -79,7 +79,7 @@ def __init__(self, m, **kwargs): value=False, close_on_click=False, close_on_content_click=False, - children=[self.w_loading, self.card], + children=[self.w_loading, card], v_slots=[slot], offset_x=True, top="bottom" in kwargs["position"], @@ -93,15 +93,13 @@ def __init__(self, m, **kwargs): # add js behaviour self.menu.observe(self.toggle_cursor, "v_model") self.m.on_interaction(self.read_data) - self.close_card_btn.on_event("click", self.close_card) - - def close_card(self, widget, event, data): - """Manually close card (menu)""" - self.menu.v_model = False + close_btn.on_event("click", lambda *_: setattr(self.menu, "v_model", False)) def toggle_cursor(self, change): - """Toggle the cursor displa on the map to notify to the user that the inspector - mode is activated""" + """ + 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] @@ -111,6 +109,9 @@ def toggle_cursor(self, change): 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" @@ -127,13 +128,13 @@ def read_data(self, **kwargs): children = [] # get the coordinates as (x, y) - coords = [round(c, 3) for c in reversed(kwargs.get("coordinates"))] + lng, lat = coords = [c for c in reversed(kwargs.get("coordinates"))] # write the coordinates children.append( sw.Html(tag="h4", children=["Coordinates (longitude, latitude)"]) ) - children.append(sw.Html(tag="p", children=[str(coords)])) + children.append(sw.Html(tag="p", children=[f"[{lng:.3f}, {lat:.3f}]"])) # write the layers data children.append(sw.Html(tag="h4", children=["Layers"])) @@ -180,7 +181,7 @@ def _from_eelayer(self, ee_obj, coords): coords (tuple): the coordinates of the point (lng, lat). Return: - (dict): tke value associated to the bad/feature names + (dict): tke value associated to the image/feature names """ # create a gee point @@ -200,7 +201,7 @@ def _from_eelayer(self, ee_obj, coords): else: pixel_values = features.first().toDictionary().getInfo() - elif isinstance(ee_obj, (ee.Image)): + elif isinstance(ee_obj, ee.Image): # reduce the layer region using mean pixel_values = ee_obj.reduceRegion( @@ -238,8 +239,8 @@ def _from_geojson(self, data, coords): # only display the columns name if empty if len(gdf_filtered) == 0: - cols = list(set(skip_cols) ^ set(gdf.columns.to_list())) - return {c: None for c in cols} + 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: @@ -269,7 +270,7 @@ def _from_raster(self, raster, coords): if da.rio.crs != CRS.from_string("EPSG:4326"): da = da.rio.reproject("EPSG:4326") - # sample is not available for da so I udo as in GEE a mean reducer around 1px + # 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 diff --git a/tests/test_ValueInspector.py b/tests/test_ValueInspector.py index c6d0e85e..a47b6a8a 100644 --- a/tests/test_ValueInspector.py +++ b/tests/test_ValueInspector.py @@ -1,173 +1,166 @@ import pytest from pathlib import Path -import shutil -import json -from configparser import ConfigParser +import ee +from urllib.request import urlretrieve +import math +import geopandas as gpd -from sepal_ui import config_file -from sepal_ui.translator import Translator +from sepal_ui import mapping as sm +from sepal_ui.scripts import utils as su -class TestTranslator: - def test_init(self, translation_folder, tmp_config_file): +class TestValueInspector: + def test_init(self): - # assert that the test key exist in fr - translator = Translator(translation_folder, "fr") - assert translator.test_key == "Clef de test" + m = sm.SepalMap() + value_inspector = sm.ValueInspector(m) + m.add_control(value_inspector) - # assert that the the code work if the path is a str - translator = Translator(str(translation_folder), "fr") - assert translator.test_key == "Clef de test" + assert isinstance(value_inspector, sm.ValueInspector) - # assert that the test does not exist in es and we fallback to en - translator = Translator(translation_folder, "es") - assert translator.test_key == "Test key" + def test_toogle_cursor(self): - # assert that using a non existing lang lead to fallback to english - translator = Translator(translation_folder, "it") - assert translator.test_key == "Test key" + m = sm.SepalMap() + value_inspector = sm.ValueInspector(m) + m.add_control(value_inspector) - # assert that if nothing is set it will use the confi_file (fr-FR) - translator = Translator(translation_folder) - assert translator.test_key == "Clef de test" + # activate the window + value_inspector.menu.v_model = True + assert m.default_style.cursor == "crosshair" - return - - def test_search_key(self): - - # assert that having a wrong key in the json will raise an error - key = "toto" - d = {"a": {"toto": "b"}, "c": "d"} - - with pytest.raises(Exception): - Translator.search_key(d, key) + # close with the menu + value_inspector.menu.v_model = False + assert m.default_style.cursor == "grab" return - def test_sanitize(self): + def test_read_data(self): - # a test dict with many embeded numbered list - # but also an already existing list - test = { - "a": {"0": "b", "1": "c"}, - "d": {"e": {"0": "f", "1": "g"}, "h": "i"}, - "j": ["k", "l"], - } + # not testing the display of anything here just the interaction + m = sm.SepalMap() + value_inspector = sm.ValueInspector(m) + m.add_control(value_inspector) - # the sanitize version of this - result = { - "a": ["b", "c"], - "d": {"e": ["f", "g"], "h": "i"}, - "j": ["k", "l"], - } + # click anywhere without activation + value_inspector.read_data(type="click", coordinates=[0, 0]) + assert len(value_inspector.text.children) == 1 - assert Translator.sanitize(test) == result + # 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 - def test_delete_empty(self): + @su.need_ee + def test_free_eelayer(self, world_temp, ee_adm2): - test = {"a": "", "b": 1, "c": {"d": ""}, "e": {"f": "", "g": 2}} - result = {"b": 1, "c": {}, "e": {"g": 2}} + # create a map with a value inspector + m = sm.SepalMap() + value_inspector = sm.ValueInspector(m) - assert Translator.delete_empty(test) == result + # check a nodata place on Image + data = value_inspector._from_eelayer(world_temp.mosaic(), [0, 0]) + assert data == {"temperature_2m": None} - def test_missing_keys(self, translation_folder): + # check vatican city + data = value_inspector._from_eelayer(world_temp.mosaic(), [12.457, 41.902]) + assert data == {"temperature_2m": 296.00286865234375} - # check that all keys are in the fr dict - translator = Translator(translation_folder, "fr") - assert translator.missing_keys() == "All messages are translated" + # check a featurecollection on nodata place + data = value_inspector._from_eelayer(ee_adm2, [0, 0]) + assert data == {"ADM2_CODE": None} - # check that 1 key is missing - translator = Translator(translation_folder, "es") - assert translator.missing_keys() == "root['test_key']" + # 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_find_target(self, translation_folder): + def test_from_geojson(self, adm0_vatican): + + # create a map with a value inspector + m = sm.SepalMap() + value_inspector = sm.ValueInspector(m) - # test grid - test_grid = { - "en": ("en", "en"), - "en-US": ("en-US", "en"), - "fr-FR": ("fr-FR", "fr-FR"), - "fr-CA": ("fr-CA", "fr"), - "fr": ("fr", "fr"), - "da": ("da", None), - } + # check a featurecollection on nodata place + data = value_inspector._from_geojson(adm0_vatican, [0, 0]) + assert data == {"GID_0": None, "NAME_0": None} - # loop in the test grid to check multiple language combinations - for k, v in test_grid.items(): - assert Translator.find_target(translation_folder, k) == v + # 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_available_locales(self, translation_folder): + def test_from_raster(self, raster_bahamas): - # expected grid - res = ["es", "fr", "fr-FR", "en"] + # create a map with a value inspector + m = sm.SepalMap() + value_inspector = sm.ValueInspector(m) - # create the translator - # -en- to -en- - translator = Translator(translation_folder) + # 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} - for locale in res: - assert locale in translator.available_locales() + # 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(scope="class") - def translation_folder(self): - """ - Generate a fully qualified translation folder with limited keys in en, fr and es. - Cannot use the temfile lib as we need the directory to appear in the tree - """ + @pytest.fixture + def world_temp(self): + """get the world temperature dataset from GEE""" - # set up the appropriate keys for each language - keys = { - "en": {"a_key": "A key", "test_key": "Test key"}, - "fr": {"a_key": "Une clef", "test_key": "Clef de test"}, - "fr-FR": {"a_key": "Une clef", "test_key": "Clef de test"}, - "es": {"a_key": "Una llave"}, - } + return ( + ee.ImageCollection("ECMWF/ERA5_LAND/HOURLY") + .filter(ee.Filter.date("2020-07-01", "2020-07-02")) + .select("temperature_2m") + ) - # generate the tmp_dir in the test directory - tmp_dir = Path(__file__).parent / "data" / "messages" - tmp_dir.mkdir(exist_ok=True, parents=True) + @pytest.fixture + def ee_adm2(self): + """get a featurecollection with only adm2code values""" - # create the translation files - for lan, d in keys.items(): - folder = tmp_dir / lan - folder.mkdir() - (folder / "locale.json").write_text(json.dumps(d, indent=2)) + return ee.FeatureCollection("FAO/GAUL/2015/level2").select("ADM2_CODE") - yield tmp_dir + @pytest.fixture + def raster_bahamas(self): + """add a raster file of the bahamas coming from rasterio test suit""" - # flush everything - shutil.rmtree(tmp_dir) + 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(scope="function") - def tmp_config_file(self): - """ - Erase any existing config file and replace it with one specifically - design for thesting the translation - """ + @pytest.fixture + def adm0_vatican(self): + """create a geojson of vatican city""" + + zip_file = Path.home() / "VAT.zip" - # erase anything that exists - if config_file.is_file(): - config_file.unlink() + if not zip_file.is_file(): + urlretrieve( + "https://biogeo.ucdavis.edu/data/gadm3.6/gpkg/gadm36_VAT_gpkg.zip", + zip_file, + ) - # create a new file - config = ConfigParser() - config.add_section("sepal-ui") - config.set("sepal-ui", "locale", "fr-FR") - config.write(config_file.open("w")) + 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 1 + yield geojson - # flush it - config_file.unlink() + zip_file.unlink() return From 5863f87ae528e1b34903406ff7a8f307b3ddc23d Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 30 May 2022 07:01:29 +0000 Subject: [PATCH 35/43] fix: include a base filter to sepal_map search and delete methods --- sepal_ui/mapping/__init__.py | 2 +- sepal_ui/mapping/{mapping.py => sepal_map.py} | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) rename sepal_ui/mapping/{mapping.py => sepal_map.py} (97%) diff --git a/sepal_ui/mapping/__init__.py b/sepal_ui/mapping/__init__.py index 3347ac52..28eabed0 100644 --- a/sepal_ui/mapping/__init__.py +++ b/sepal_ui/mapping/__init__.py @@ -1,4 +1,4 @@ -from .mapping import * +from .sepal_map import * from .fullscreen_control import * from .draw_control import * from .value_inspector import * diff --git a/sepal_ui/mapping/mapping.py b/sepal_ui/mapping/sepal_map.py similarity index 97% rename from sepal_ui/mapping/mapping.py rename to sepal_ui/mapping/sepal_map.py index 00e51604..9b2517ea 100644 --- a/sepal_ui/mapping/mapping.py +++ b/sepal_ui/mapping/sepal_map.py @@ -623,7 +623,7 @@ def get_basemap_list(): @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 @@ -694,21 +694,17 @@ def get_viz_params(image): return props - def remove_layer(self, key): + def remove_layer(self, key, base=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 """ - if isinstance(key, (int, str, ipl.Layer)): - layer = self.find_layer(key) - else: - raise ValueError( - f"Key must be of type 'str', 'int' or 'Layer'. {type(key)} given." - ) + layer = self.find_layer(key, base) # catch if the layer doesn't exist if layer is None: @@ -786,24 +782,28 @@ def get_scale(self): return 156543.04 * math.cos(0) / math.pow(2, self.zoom) - def find_layer(self, key): + def find_layer(self, key, base=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 t 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 self.layers if lyr.name == key), None) + layer = next((lyr for lyr in layers if lyr.name == key), None) elif isinstance(key, int): - size = len(self.layers) - layer = self.layers[key] if -size <= key < size else None + size = len(layers) + layer = layers[key] if -size <= key < size else None elif isinstance(key, ipl.Layer): - layer = next((lyr for lyr in self.layers if lyr == key), None) + 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") From c56a0ea9218c01a9ca3e665451dcad3d3e42b32a Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 30 May 2022 09:31:50 +0000 Subject: [PATCH 36/43] test: SepalMap --- .../modules/sepal_ui.mapping.SepalMap.rst | 77 ++++- sepal_ui/mapping/basemaps.py | 2 +- sepal_ui/mapping/sepal_map.py | 33 +- tests/test_SepalMap.py | 326 +++++++++--------- 4 files changed, 249 insertions(+), 189 deletions(-) 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/sepal_ui/mapping/basemaps.py b/sepal_ui/mapping/basemaps.py index 53bc8f48..4b75b464 100644 --- a/sepal_ui/mapping/basemaps.py +++ b/sepal_ui/mapping/basemaps.py @@ -93,5 +93,5 @@ def xyz_to_leaflet(): return leaflet_dict -basemaps = BasemapBox(xyz_to_leaflet(), frozen_box=True) +basemap_tiles = BasemapBox(xyz_to_leaflet(), frozen_box=True) "(Box.box): the basemaps list as a box" diff --git a/sepal_ui/mapping/sepal_map.py b/sepal_ui/mapping/sepal_map.py index 9b2517ea..342a0048 100644 --- a/sepal_ui/mapping/sepal_map.py +++ b/sepal_ui/mapping/sepal_map.py @@ -20,7 +20,6 @@ from matplotlib import colorbar import ipywidgets as widgets from rasterio.crs import CRS -from traitlets import Bool import ipyvuetify as v import ipyleaflet as ipl import ee @@ -33,7 +32,7 @@ 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 basemaps +from sepal_ui.mapping.basemaps import basemap_tiles __all__ = ["SepalMap"] @@ -67,18 +66,16 @@ class SepalMap(ipl.Map): # ########################################################################## 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" + 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["center"] = kwargs.pop("center", [0, 0]) kwargs["zoom"] = kwargs.pop("zoom", 2) @@ -86,6 +83,7 @@ def __init__(self, basemaps=[], dc=False, vinspector=False, gee=True, **kwargs): 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) @@ -160,7 +158,7 @@ def set_center(self, lon, lat, zoom=None): """ self.center = [lat, lon] - self.zoom = zoom or self.zoom + self.zoom = self.zoom if zoom is None else zoom return @@ -424,7 +422,6 @@ def add_colorbar( output.clear_output() plt.show() - self.colorbar = colormap_ctrl self.add_control(colormap_ctrl) return @@ -618,7 +615,7 @@ def get_basemap_list(): ([str]): the list of the basemap names """ - return [k for k in basemaps.keys()] + return [k for k in basemap_tiles.keys()] @staticmethod def get_viz_params(image): @@ -708,7 +705,7 @@ def remove_layer(self, key, base=False): # catch if the layer doesn't exist if layer is None: - raise ipl.LayerException(f"layer not on map:{key}") + raise ipl.LayerException(f"layer not on map: {key}") super().remove_layer(layer) @@ -723,12 +720,10 @@ def remove_all(self, base=False): base (bool, optional): wether or not the basemaps should be removed, default to False """ # filter out the basemaps if base == False - all_layers = (tl for tl in self.layers) - all_layers_no_basmaps = (tl for tl in self.layers if not tl.base) - gen = all_layers if base else all_layers_no_basmaps + layers = self.layers if base else [lyr for lyr in self.layers if not lyr.base] - # remove them using the built generator - [self.remove_layer(layer) for layer in gen] + # remove them using the layer objects as keys + [self.remove_layer(layer, base) for layer in layers] return @@ -762,12 +757,12 @@ def add_basemap(self, basemap="HYBRID"): Args: basemap (str, optional): Can be one of string from basemaps. Defaults to 'HYBRID'. """ - if basemap not in basemaps.keys(): - keys = "\n".join(basemaps.keys()) + 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) - self.add_layer(basemaps[basemap]) + self.add_layer(basemap_tiles[basemap]) return diff --git a/tests/test_SepalMap.py b/tests/test_SepalMap.py index fe638c1b..c2054815 100644 --- a/tests/test_SepalMap.py +++ b/tests/test_SepalMap.py @@ -1,8 +1,11 @@ from pathlib import Path +from random import randint +from urllib.request import urlretrieve +import math import pytest import ee -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 @@ -20,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, sm.DrawControl) - # check that the map start with several basemaps basemaps = ["CartoDB.DarkMatter", "CartoDB.Positron"] m = sm.SepalMap(basemaps) @@ -31,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): @@ -41,70 +44,17 @@ def test_init(self): return - @pytest.mark.skip(reason="the method is now deprecated") - 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, sm.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, sm.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,107 @@ 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" + + res = m.find_layer("toto") + 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" + + res = m.find_layer(50) # out of bounds + assert res is None + + # 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(Exception): + 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 From dcb66b5916788afef9e96d35d527d17954c70bdd Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 30 May 2022 09:37:19 +0000 Subject: [PATCH 37/43] build: duplicate dependency --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index 2b6b67a8..46ee09f9 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,6 @@ def run(self): "xyzservices", "planet", "pyyaml", - "python-box", ], "extras_require": { "dev": [ From baafa310a0ad26241c23b52868b33248ab421702 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 30 May 2022 09:44:15 +0000 Subject: [PATCH 38/43] docs: typo in function prototype --- docs/source/modules/sepal_ui.mapping.basemaps.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/modules/sepal_ui.mapping.basemaps.rst b/docs/source/modules/sepal_ui.mapping.basemaps.rst index 6af39f6a..0ef92a6c 100644 --- a/docs/source/modules/sepal_ui.mapping.basemaps.rst +++ b/docs/source/modules/sepal_ui.mapping.basemaps.rst @@ -11,6 +11,6 @@ sepal\_ui.mapping.basemaps get_xyz_dict xyz_to_leaflet -.. autofunction:: sepal_ui.mapping.get_xyz_dict +.. autofunction:: sepal_ui.mapping.basemaps.get_xyz_dict -.. autofunction:: sepal_ui.mapping.xyz_to_leaflet \ No newline at end of file +.. autofunction:: sepal_ui.mapping.basemaps.xyz_to_leaflet \ No newline at end of file From f2fcf279d031f648ce268acde320e992d24223bf Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 30 May 2022 09:55:16 +0000 Subject: [PATCH 39/43] docs: avoid bug in the documentation display --- sepal_ui/mapping/basemaps.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sepal_ui/mapping/basemaps.py b/sepal_ui/mapping/basemaps.py index 4b75b464..9f594eeb 100644 --- a/sepal_ui/mapping/basemaps.py +++ b/sepal_ui/mapping/basemaps.py @@ -42,7 +42,7 @@ def __repr__(self): "(dict): Custom XYZ tile services." -def get_xyz_dict(free_only=True, _collection=xyz, _output={}): +def get_xyz_dict(free_only=True, _collection=None, _output=None): """ Returns a dictionary of xyz services. @@ -53,6 +53,9 @@ def get_xyz_dict(free_only=True, _collection=xyz, _output={}): 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): From 1553217ea32b12d8d2a7e41bf8587c74395a990a Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 30 May 2022 10:05:17 +0000 Subject: [PATCH 40/43] build: add dask to requirements --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 46ee09f9..0b9507a5 100644 --- a/setup.py +++ b/setup.py @@ -50,6 +50,7 @@ def run(self): "xyzservices", "planet", "pyyaml", + "dask", ], "extras_require": { "dev": [ From 015aa57a797879ac8e8cdc7b7ba6915371f11c7f Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 30 May 2022 10:54:00 +0000 Subject: [PATCH 41/43] refactor: use keys for vinspector messages --- sepal_ui/mapping/value_inspector.py | 24 ++++++++++++++---------- sepal_ui/message/en/v_inspector.json | 12 ++++++++++++ 2 files changed, 26 insertions(+), 10 deletions(-) create mode 100644 sepal_ui/message/en/v_inspector.json diff --git a/sepal_ui/mapping/value_inspector.py b/sepal_ui/mapping/value_inspector.py index 68bb55ed..f848018f 100644 --- a/sepal_ui/mapping/value_inspector.py +++ b/sepal_ui/mapping/value_inspector.py @@ -14,6 +14,7 @@ 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) @@ -61,9 +62,9 @@ def __init__(self, m, **kwargs): 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=["Inspector"]) + 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=["select a point"]) + self.text = sw.CardText(children=[ms.v_inspector.landing]) card = sw.Card( tile=True, color=color.menu, @@ -73,7 +74,7 @@ def __init__(self, m, **kwargs): style_="overflow: auto", ) - # assempble everything in a menu + # assemble everything in a menu self.menu = sw.Menu( v_model=False, value=False, @@ -130,10 +131,9 @@ def read_data(self, **kwargs): # get the coordinates as (x, y) lng, lat = coords = [c for c in reversed(kwargs.get("coordinates"))] - # write the coordinates - children.append( - sw.Html(tag="h4", children=["Coordinates (longitude, latitude)"]) - ) + # 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 @@ -149,7 +149,7 @@ def read_data(self, **kwargs): elif isinstance(lyr, LocalTileLayer): data = self._from_raster(lyr.raster, coords) else: - data = {"info": "data reading method not yet ready"} + 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}"])) @@ -277,10 +277,14 @@ def _from_raster(self, raster, coords): 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 = {f"band {i+1}": v for i, v in enumerate(means)} + 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 = {f"band {i+1}": None for i in range(da.rio.count)} + 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..f1f9cbd1 --- /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 From dc74419ebdb9c6be9a22ba847899b569a0b784a2 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Mon, 30 May 2022 11:00:50 +0000 Subject: [PATCH 42/43] fix: typo --- sepal_ui/message/en/v_inspector.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sepal_ui/message/en/v_inspector.json b/sepal_ui/message/en/v_inspector.json index f1f9cbd1..0babd84a 100644 --- a/sepal_ui/message/en/v_inspector.json +++ b/sepal_ui/message/en/v_inspector.json @@ -6,7 +6,7 @@ "header": "info", "text": "data reading method not yet ready" }, - "band": "band {}:", + "band": "band {}", "coords": "Coordinates (lng, lat) at {} m/px" } } \ No newline at end of file From 6ed17a28e2aca8d5540736afe5de2bb4ec65f731 Mon Sep 17 00:00:00 2001 From: 12rambau Date: Tue, 31 May 2022 15:00:34 +0000 Subject: [PATCH 43/43] fix: add the none_ok parameter to find_layer --- sepal_ui/mapping/sepal_map.py | 15 ++++++++++----- tests/test_SepalMap.py | 12 ++++++++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/sepal_ui/mapping/sepal_map.py b/sepal_ui/mapping/sepal_map.py index 342a0048..244d303c 100644 --- a/sepal_ui/mapping/sepal_map.py +++ b/sepal_ui/mapping/sepal_map.py @@ -691,7 +691,7 @@ def get_viz_params(image): return props - def remove_layer(self, key, base=False): + 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 @@ -699,9 +699,10 @@ def remove_layer(self, key, base=False): 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) + layer = self.find_layer(key, base, none_ok) # catch if the layer doesn't exist if layer is None: @@ -737,7 +738,7 @@ def add_layer(self, layer, hover=False): """ # remove existing layer before addition - existing_layer = self.find_layer(layer.name) + existing_layer = self.find_layer(layer.name, none_ok=True) not existing_layer or self.remove_layer(existing_layer) # apply default coloring for geoJson @@ -777,13 +778,14 @@ def get_scale(self): return 156543.04 * math.cos(0) / math.pow(2, self.zoom) - def find_layer(self, key, base=False): + 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 t false + 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 @@ -802,6 +804,9 @@ def find_layer(self, key, base=False): 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 # ########################################################################## diff --git a/tests/test_SepalMap.py b/tests/test_SepalMap.py index c2054815..c98922da 100644 --- a/tests/test_SepalMap.py +++ b/tests/test_SepalMap.py @@ -323,7 +323,10 @@ def test_find_layer(self, ee_map_with_layers): res = m.find_layer("Classification") assert res.name == "Classification" - res = m.find_layer("toto") + # 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 @@ -333,8 +336,9 @@ def test_find_layer(self, ee_map_with_layers): res = m.find_layer(-1) assert res.name == "RGB" - res = m.find_layer(50) # out of bounds - assert res is None + # out of bounds + with pytest.raises(ValueError): + res = m.find_layer(50) # search by layer res = m.find_layer(m.layers[2]) @@ -346,7 +350,7 @@ def test_find_layer(self, ee_map_with_layers): assert res.base is True # search something that is not a key - with pytest.raises(Exception): + with pytest.raises(ValueError): m.find_layer(m) return