"""The customized ``Map`` object."""
# known bug of rasterio
import os
if "GDAL_DATA" in list(os.environ.keys()):
del os.environ["GDAL_DATA"]
if "PROJ_LIB" in list(os.environ.keys()):
del os.environ["PROJ_LIB"]
import json
import math
import random
import string
import warnings
from distutils.util import strtobool
from pathlib import Path
from typing import List, Optional, Sequence, Union, cast
import ee
import ipyleaflet as ipl
import ipyvuetify as v
import ipywidgets as widgets
import matplotlib.pyplot as plt
import numpy as np
import rioxarray
from deprecated.sphinx import deprecated
from localtileserver import TileClient, get_leaflet_tile_layer
from matplotlib import colorbar
from matplotlib import colors as mpc
from rasterio.crs import CRS
from typing_extensions import Self
from sepal_ui import color as scolors
from sepal_ui import sepalwidgets as sw
from sepal_ui.frontend import styles as ss
from sepal_ui.mapping.basemaps import basemap_tiles
from sepal_ui.mapping.draw_control import DrawControl
from sepal_ui.mapping.inspector_control import InspectorControl
from sepal_ui.mapping.layer import EELayer
from sepal_ui.mapping.layer_state_control import LayerStateControl
from sepal_ui.mapping.layers_control import LayersControl
from sepal_ui.mapping.legend_control import LegendControl
from sepal_ui.mapping.zoom_control import ZoomControl
from sepal_ui.message import ms
from sepal_ui.scripts import decorator as sd
from sepal_ui.scripts import utils as su
from sepal_ui.scripts.warning import SepalWarning
__all__ = ["SepalMap"]
[docs]
class SepalMap(ipl.Map):
# ##########################################################################
# ### Map parameters ###
# ##########################################################################
gee: bool = True
"Either the map will use ee binding or not"
v_inspector: Optional[InspectorControl] = None
"The value inspector of the map"
dc: Optional[DrawControl] = None
"The drawing control of the map"
_id: str = ""
"A unique 6 letters str to identify the map in the DOM"
state: Optional[sw.StateBar] = None
"The statebar to inform the user about tile loading"
[docs]
def __init__(
self,
basemaps: List[str] = [],
dc: bool = False,
vinspector: bool = False,
gee: bool = True,
statebar: bool = False,
**kwargs,
) -> None:
"""Custom Map object design to build application.
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: the basemaps used as background in the map. If multiple selection, they will be displayed as layers.
dc: whether or not the drawing control should be displayed. default to false
vinspector: Add value inspector to map, useful to inspect pixel values. default to false
gee: whether or not to use the ee binding. If False none of the earthengine display functionalities can be used. default to True
statebar: whether or not to display the Statebar in the map
kwargs (optional): any parameter from a ipyleaflet.Map. if set, 'ee_initialize' will be overwritten.
"""
# set the default parameters
kwargs.setdefault("center", [0, 0])
kwargs.setdefault("zoom", 2)
kwargs.setdefault("max_zoom", 24)
kwargs["basemap"] = {}
kwargs["zoom_control"] = False
kwargs["attribution_control"] = False
kwargs["scroll_wheel_zoom"] = True
kwargs.setdefault("world_copy_jump", True)
# Init the map
super().__init__(**kwargs)
# init ee
self.gee = gee
not gee or su.init_ee()
# add the basemaps
self.clear()
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)]
# set the visibility of all the basemaps to False but the first one
[setattr(lyr, "visible", False) for lyr in self.layers]
self.layers[0].visible = True
# add the base controls
self.add(ZoomControl(self))
self.add(LayersControl(self, group=-1))
self.add(ipl.AttributionControl(position="bottomleft", prefix="SEPAL"))
self.add(ipl.ScaleControl(position="bottomleft", imperial=False))
# specific drawing control
self.dc = DrawControl(self)
not dc or self.add(self.dc)
# specific v_inspector
self.v_inspector = InspectorControl(self)
not vinspector or self.add(self.v_inspector)
# specific statebar
self.state = LayerStateControl(self)
not statebar or self.add(self.state)
# create a proxy ID to the element
# this id should be unique and will be used by mutators to identify this map
self._id = "".join(random.choice(string.ascii_lowercase) for i in range(6))
self.add_class(self._id)
@deprecated(version="2.8.0", reason="the local_layer stored list has been dropped")
def _remove_local_raster(self, local_layer: str) -> Self:
"""Remove local layer from memory.
.. danger::
Does nothing now.
Args:
local_layer (str | ipyleaflet.TileLayer): The local layer to remove or its name
"""
return self
[docs]
@deprecated(version="2.8.0", reason="use remove_layer(-1) instead")
def remove_last_layer(self, local: bool = False) -> Self:
"""Remove last added layer from Map.
Args:
local: Specify True to only remove local last layers, otherwise will remove every last layer.
"""
self.remove_layer(-1)
return self
[docs]
def set_center(self, lon: float, lat: float, zoom: int = -1) -> None:
"""Centers the map view at a given coordinates with the given zoom level.
Args:
lon: The longitude of the center, in degrees.
lat: The latitude of the center, in degrees.
zoom: The zoom level, from 1 to 24. Defaults to None.
"""
self.center = [lat, lon]
self.zoom = self.zoom if zoom == -1 else zoom
return
[docs]
@sd.need_ee
def zoom_ee_object(self, item: ee.ComputedObject, zoom_out: int = 1) -> Self:
"""Get the proper zoom to the given ee geometry.
Args:
item: the geometry to zoom on
zoom_out: Zoom out the bounding zoom
"""
# type check the given object
ee_geometry = item if isinstance(item, ee.Geometry) else item.geometry()
# extract bounds from ee_object
coords = ee_geometry.bounds().coordinates().get(0).getInfo()
# zoom on these bounds
return self.zoom_bounds((*coords[0], *coords[2]), zoom_out)
[docs]
def zoom_raster(self, layer: ipl.LocalTileLayer, zoom_out: int = 1) -> Self:
"""Adapt the zoom to the given LocalLayer.
The localLayer need to come from the add_raster method to embed the image name.
Args:
layer: the localTile layer to zoom on. it needs to embed the "raster" member
zoom_out: Zoom out the bounding zoom
"""
da = rioxarray.open_rasterio(layer.raster, masked=True)
# unproject if necessary
epsg_4326 = "EPSG:4326"
if da.rio.crs != CRS.from_string(epsg_4326):
da = da.rio.reproject(epsg_4326)
return self.zoom_bounds(da.rio.bounds(), zoom_out)
[docs]
def zoom_bounds(self, bounds: Sequence[float], zoom_out: int = 1) -> Self:
"""Adapt the zoom to the given bounds. and center the image.
Args:
bounds: coordinates corners as minx, miny, maxx, maxy in EPSG:4326
zoom_out: Zoom out the bounding zoom
"""
# center the map
minx, miny, maxx, maxy = bounds
self.fit_bounds([[miny, minx], [maxy, maxx]])
# adapt the zoom level
zoom_out = (self.zoom - 1) if zoom_out > self.zoom else zoom_out
self.zoom -= zoom_out
return self
[docs]
def add_raster(
self,
image: Union[str, Path],
bands: Optional[Union[list, int]] = None,
layer_name: str = "Layer_" + su.random_string(),
colormap: Union[str, mpc.Colormap] = "inferno",
opacity: float = 1.0,
fit_bounds: bool = True,
key: str = "",
) -> ipl.TileLayer:
"""Adds a local raster dataset to the map.
If used on a cloud platform (or distant jupyter), this method won't know where the entry point of the client is set and will thus fail to display the image. Please follow instructions from https://localtileserver.banesullivan.com/installation/remote-jupyter.html and set up the ``LOCALTILESERVER_CLIENT_PREFIX`` environment variable.
Args:
image: The image file path.
bands: 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: 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: 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 inferno.
opacity: the opacity of the layer, default 1.0.
key: the unequivocal key of the layer. by default use a normalized str of the layer name
fit_bounds: Whether or not we should fit the map to the image bounds. Default to True.
Returns:
the local tile layer embedding the raster member (to be used with other tools of sepal-ui)
"""
# force cast to Path and then start the client
image = Path(image)
if not image.is_file():
raise Exception(ms.mapping.no_image)
client = TileClient(image)
# check inputs
if layer_name in [layer.name for layer in self.layers]:
layer_name = layer_name + su.random_string()
# set the colors as independent colors
if isinstance(colormap, str):
cmap = plt.get_cmap(name=colormap)
color_list = [mpc.rgb2hex(cmap(i)) for i in range(cmap.N)]
da = rioxarray.open_rasterio(image, masked=True)
da = da.chunk((1000, 1000))
multi_band = False
if len(da.band) > 1 and not isinstance(bands, int):
multi_band = True
bands = bands if bands else [3, 2, 1]
elif len(da.band) == 1:
bands = 1
if multi_band:
cast(list, bands)
style = {
"bands": [
{"band": bands[0], "palette": "#f00"},
{"band": bands[1], "palette": "#0f0"},
{"band": bands[2], "palette": "#00f"},
]
}
else:
style = {
"bands": [
{"band": bands, "palette": color_list},
]
}
# create the layer
layer = get_leaflet_tile_layer(
client,
style=style,
name=layer_name,
opacity=opacity,
max_zoom=20,
max_native_zoom=20,
)
self.add_layer(layer, key=key)
# add the da to the layer as an extra member for the v_inspector
layer.raster = str(image)
# zoom on the layer if requested
if fit_bounds is True:
self.center = client.center()
self.zoom = client.default_zoom
return layer
[docs]
@deprecated(version="2.8.0", reason="use dc methods instead")
def show_dc(self) -> Self:
"""Show the drawing control on the map."""
self.dc.show()
return self
[docs]
@deprecated(version="2.8.0", reason="use dc methods instead")
def hide_dc(self) -> Self:
"""Hide the drawing control of the map."""
self.dc.hide()
return self
[docs]
def add_colorbar(
self,
colors: list,
cmap: str = "viridis",
vmin: float = 0.0,
vmax: float = 1.0,
index: list = [],
categorical: bool = False,
step: int = 0,
transparent_bg: bool = False,
position: str = "bottomright",
layer_name: str = "",
**kwargs,
) -> None:
"""Add a colorbar to the map.
Args:
colors: The set of colors to be used for interpolation. Colors can be provided in the form: * tuples of RGBA ints between 0 and 255 (e.g: (255, 255, 0) or (255, 255, 0, 255)) * tuples of RGBA floats between 0. and 1. (e.g: (1.,1.,0.) or (1., 1., 0., 1.)) * HTML-like string (e.g: “#ffff00) * a color name or shortcut (e.g: “y” or “yellow”)
cmap: a matplotlib colormap default to viridis
vmin: The minimal value for the colormap. Values lower than vmin will be bound directly to colors[0].. Defaults to 0.
vmax: The maximal value for the colormap. Values higher than vmax will be bound directly to colors[-1]. Defaults to 1.0.
index: The values corresponding to each color. It has to be sorted, and have the same length as colors. If None, a regular grid between vmin and vmax is created. Defaults to None.
categorical (bool, optional): Whether or not to create a categorical colormap. Defaults to False.
step: The step to split the LinearColormap into a StepColormap. Defaults to None.
position: The position for the colormap widget. Defaults to "bottomright".
layer_name: Layer name of the colorbar to be associated with. Defaults to None.
kwargs: any other argument of the colorbar object from matplotlib
"""
width, height = 6.0, 0.4
alpha = 1
if colors is not None:
# transform colors in hex colors
hexcodes = [su.to_colors(c) for c in colors]
if categorical:
plot_color = mpc.ListedColormap(hexcodes)
vals = np.linspace(vmin, vmax, plot_color.N + 1)
norm = mpc.BoundaryNorm(vals, plot_color.N)
else:
plot_color = mpc.LinearSegmentedColormap.from_list(
"custom", hexcodes, N=256
)
norm = mpc.Normalize(vmin=vmin, vmax=vmax)
elif cmap is not None:
plot_color = plt.get_cmap(cmap)
norm = mpc.Normalize(vmin=vmin, vmax=vmax)
else:
msg = '"cmap" keyword or "colors" key must be provided.'
raise ValueError(msg)
style = "dark_background" if v.theme.dark is True else "classic"
with plt.style.context(style):
fig, ax = plt.subplots(figsize=(width, height))
cb = colorbar.ColorbarBase(
ax,
norm=norm,
alpha=alpha,
cmap=plot_color,
orientation="horizontal",
**kwargs,
)
# cosmetic improvement
cb.outline.set_visible(False) # remove border of the color bar
ax.tick_params(size=0) # remove ticks
fig.patch.set_alpha(0.0) # remove bg of the fig
ax.patch.set_alpha(0.0) # remove bg of the ax
not layer_name or cb.set_label(layer_name)
output = widgets.Output()
colormap_ctrl = ipl.WidgetControl(
widget=output,
position=position,
transparent_bg=True,
)
with output:
output.clear_output()
plt.show()
self.add(colormap_ctrl)
return
[docs]
def add_ee_layer(
self,
ee_object: ee.ComputedObject,
vis_params: dict = {},
name: str = "",
shown: bool = True,
opacity: float = 1.0,
viz_name: str = "",
key: str = "",
) -> None:
"""Customized add_layer method designed for EE objects.
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 visualization metadata exist, SepalMap will use
them automatically.
Args:
ee_object: the ee OBject to draw on the map
vis_params: the visualization parameters set as in GEE
name: the name of the layer
shown: either to show the layer or not, default to true (it is bugged in ipyleaflet)
opacity: the opcity of the layer from 0 to 1, default to 1.
viz_name: the name of the vizaulization you want to use. default to the first one if existing
key: the unequivocal key of the layer. by default use a normalized str of the layer name
"""
# check the type of the ee object and raise an error if it's not recognized
if not isinstance(
ee_object,
(
ee.Image,
ee.ImageCollection,
ee.FeatureCollection,
ee.Feature,
ee.Geometry,
),
):
raise AttributeError(
"\n\nThe image argument in 'addLayer' function must be an instance of "
"one of ee.Image, ee.Geometry, ee.Feature or ee.FeatureCollection."
)
# get the list of viz params
viz = self.get_viz_params(ee_object)
# get the requested vizparameters name
# if non is set use the first one
if viz:
viz_name = viz_name or viz[next(iter(viz))]["name"]
# apply it to vis_params
if not vis_params and viz:
# find the viz params in the list
try:
vis_params = next(i for p, i in viz.items() if i["name"] == viz_name)
except StopIteration:
raise ValueError(
f"the provided viz_name ({viz_name}) cannot be found in the image metadata"
)
# invert the bands if needed
inverted = vis_params.pop("inverted", None)
if inverted is not None:
# get the index of the bands that need to be inverted
index_list = [i for i, v in enumerate(inverted) if v is True]
# multiply everything by -1
for i in index_list:
min_ = vis_params["min"][i]
max_ = vis_params["max"][i]
vis_params["min"][i] = max_
vis_params["max"][i] = min_
# specific case of categorical images
# Pad the palette when using non-consecutive values
# instead of remapping or using sldStyle
# to preserve the class values in the image, for inspection
if vis_params["type"] == "categorical":
colors = vis_params["palette"]
values = vis_params["values"]
min_ = min(values)
max_ = max(values)
# set up a black palette of correct length
palette = ["#000000"] * (max_ - min_ + 1)
# replace the values within the palette
for i, val in enumerate(values):
palette[val - min_] = colors[i]
# adapt the vizparams
vis_params["palette"] = palette
vis_params["min"] = min_
vis_params["max"] = max_
# specific case of hsv
elif vis_params["type"] == "hsv":
# set to_min to 0 and to_max to 1
# in the original expression:
# 'to_min + (v - from_min) * (to_max - to_min) / (from_max - from_min)'
expression = (
"{band} = (b('{band}') - {from_min}) / ({from_max} - {from_min})"
)
# get the maxs and mins
# removing them from the parameter
mins = vis_params.pop("min")
maxs = vis_params.pop("max")
# create the rgb bands
asset = ee_object
for i, band in enumerate(vis_params["bands"]):
# adapt the expression
exp = expression.format(
from_min=mins[i], from_max=maxs[i], band=band
)
asset = asset.addBands(asset.expression(exp), [band], True)
# set the arguments
ee_object = asset.select(vis_params["bands"]).hsvToRgb()
vis_params["bands"] = ["red", "green", "blue"]
# create the layer based on these new values
if not name:
layer_count = len(self.layers)
name = "Layer " + str(layer_count + 1)
# force cast to featureCollection if needed
if isinstance(
ee_object,
(
ee.geometry.Geometry,
ee.feature.Feature,
ee.featurecollection.FeatureCollection,
),
):
default_vis = json.loads((ss.JSON_DIR / "layer.json").read_text())[
"ee_layer"
]
default_vis.update(color=scolors.primary)
# We want to get all the default styles and only change those whose are
# in the provided visualization.
default_vis.update(vis_params)
vis_params = default_vis
features = ee.FeatureCollection(ee_object)
const_image = ee.Image.constant(0.5)
try:
image_fill = features.style(**vis_params).updateMask(const_image)
image_outline = features.style(**vis_params)
except AttributeError:
# Raise a more understandable error
raise AttributeError(
"You can only use the following styles: 'color', 'pointSize', "
"'pointShape', 'width', 'fillColor', 'styleProperty', "
"'neighborhood', 'lineType'"
)
image = image_fill.blend(image_outline)
obj = features
# use directly the ee object if Image
elif isinstance(ee_object, ee.image.Image):
image = obj = ee_object
# use mosaicing if the ee_object is a ImageCollection
elif isinstance(ee_object, ee.imagecollection.ImageCollection):
image = obj = ee_object.mosaic()
# create the colored image
map_id_dict = ee.Image(image).getMapId(vis_params)
tile_layer = EELayer(
ee_object=obj,
url=map_id_dict["tile_fetcher"].url_format,
attribution="Google Earth Engine",
name=name,
opacity=opacity,
visible=shown,
max_zoom=24,
)
self.add_layer(tile_layer, key=key)
return
[docs]
@staticmethod
def get_basemap_list() -> List[str]:
"""Get the complete list of available basemaps.
This function is intending for development use
It give the list of all the available basemaps for SepalMap object.
Returns:
The list of the basemap names
"""
return [k for k in basemap_tiles.keys()]
[docs]
@staticmethod
def get_viz_params(image: ee.Image) -> dict:
"""Return the vizual parameters that are set in the metadata of the image.
Args:
image: the image to analyse
Returns:
The dictionary of the find properties
"""
# the constant prefix for SEPAL visualization parameters
PREFIX = "visualization"
# init the property list
props = {}
# check image type
if not isinstance(image, ee.Image):
return props
# check that image have properties
if "properties" not in image.getInfo():
return props
# build a raw prop list
raw_prop_list = {
p: val
for p, val in image.getInfo()["properties"].items()
if p.startswith(PREFIX)
}
# decompose each property by its number
# and gather the properties in a sub dictionary
for p, val in raw_prop_list.items():
# extract the number and create the sub-dict
_, number, name = p.split("_")
props.setdefault(number, {})
# modify the values according to prop key
if isinstance(val, str):
if name in ["bands", "palette", "labels"]:
val = val.split(",")
elif name in ["max", "min", "values"]:
val = [float(i) for i in val.split(",")]
elif name in ["inverted"]:
val = [bool(strtobool(i)) for i in val.split(",")]
# set the value
props[number][name] = val
for i in props.keys():
if "type" in props[i]:
# categorical values need to be cast to int
if props[i]["type"] == "categorical":
props[i]["values"] = [int(val) for val in props[i]["values"]]
else:
# if no "type" is provided guess it from the different parameters gathered
if len(props[i]["bands"]) == 1:
props[i]["type"] = "continuous"
elif len(props[i]["bands"]) == 3:
props[i]["type"] = "rgb"
else:
warnings.warn(
"the embed viz properties are incomplete or badly set, "
"please review our documentation",
SepalWarning,
)
props = {}
return props
[docs]
def remove_layer(
self, key: Union[ipl.Layer, int, str], base: bool = False, none_ok: bool = False
) -> None:
"""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: the key to find the layer to delete
base: either the basemaps should be included in the search or not. default t false
none_ok: if True the function will not raise error if no layer is found. Default to False
"""
layer = self.find_layer(key, base, none_ok)
# the error is caught in find_layer
if layer is not None:
super().remove(layer)
return
[docs]
def remove_all(self, base: bool = False) -> None:
"""Remove all the layers from the maps.
If base is set to True, the basemaps are removed as well.
Args:
base: whether or not the basemaps should be removed, default to False
"""
# filter out the basemaps if base == False
layers = self.layers if base else [lyr for lyr in self.layers if not lyr.base]
# remove them using the layer objects as keys
[self.remove_layer(layer, base) for layer in layers]
return
[docs]
def add_layer(self, layer: ipl.Layer, hover: bool = False, key: str = "") -> None:
"""Add layer and use a default style for the GeoJSON inputs.
Remove existing layer if already on the map.
Args:
layer: any layer type from ipyleaflet
hover: whether to use the default hover style or not.
key: the unequivocal key of the layer. by default use a normalized str of the layer name
"""
# set up a unique key
layer.key = key if key else su.normalize_str(layer.name)
# remove existing layer before addition
existing_layer = self.find_layer(layer.key, none_ok=True)
not existing_layer or self.remove_layer(existing_layer)
# apply default coloring for geoJson
if isinstance(layer, ipl.GeoJSON):
# define the default values
default_style = json.loads((ss.JSON_DIR / "layer.json").read_text())[
"layer"
]
default_style.update(color=scolors.primary)
default_hover_style = json.loads(
(ss.JSON_DIR / "layer_hover.json").read_text()
)
default_hover_style.update(color=scolors.primary)
# apply the style depending on the parameters
layer.style = layer.style or default_style
hover_style = default_hover_style if hover else layer.hover_style
layer.hover_style = layer.hover_style or hover_style
super().add(layer)
return
[docs]
def add_basemap(self, basemap: str = "HYBRID") -> None:
"""Adds a basemap to the map.
Args:
basemap: Can be one of string from basemaps. Defaults to 'HYBRID'.
"""
if basemap not in basemap_tiles.keys():
keys = "\n".join(basemap_tiles.keys())
msg = f"Basemap can only be one of the following:\n{keys}"
raise ValueError(msg)
self.add_layer(basemap_tiles[basemap])
return
[docs]
def get_scale(self) -> float:
"""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:
Map resolution in meters.
"""
return 156543.04 * math.cos(0) / math.pow(2, self.zoom)
[docs]
def find_layer(
self, key: Union[ipl.Layer, str, int], base: bool = False, none_ok: bool = False
) -> ipl.TileLayer:
"""Search a layer by name or index.
Args:
key: the layer name, the layer key, the index or directly the layer
base: either the basemaps should be included in the search or not. default to false
none_ok: if True the function will not raise error if no layer is found. Default to False
Returns:
The first layer using the same name or index else None
"""
# filter the layers
layers = self.layers if base else [lyr for lyr in self.layers if not lyr.base]
if isinstance(key, str):
layer = next((lyr for lyr in layers if lyr.key == key), None)
layer = layer or next((lyr for lyr in layers if lyr.name == key), None)
elif isinstance(key, int):
size = len(layers)
layer = layers[key] if -size <= key < size else None
elif isinstance(key, ipl.Layer):
layer = next((lyr for lyr in layers if lyr == key), None)
else:
raise ValueError(f"key must be a int or a str, {type(key)} given")
if layer is None and none_ok is False:
raise ValueError(f"no layer corresponding to {key} on the map")
return layer
[docs]
def add_legend(
self,
title: str = ms.mapping.legend,
legend_dict: dict = {},
position: str = "bottomright",
vertical: bool = True,
) -> None:
"""Creates and adds a custom legend as widget control to the map.
Args:
title: Title of the legend. Defaults to 'Legend'.
legend_dict: dictionary with key as label name and value as color
position: the position (corners) of the legend on the map
vertical: vertical or horizoal position of the legend
"""
# Define as class member so it can be accessed from outside.
self.legend = LegendControl(
legend_dict, title=title, vertical=vertical, position=position
)
return self.add(self.legend)
# ##########################################################################
# ### overwrite geemap calls ###
# ##########################################################################
setCenter = set_center
centerObject = zoom_ee_object
addLayer = add_ee_layer
getScale = get_scale