"""``Card`` object dedicated to AOI selection. It does not include maps."""
from datetime import datetime as dt
from pathlib import Path
from typing import Dict, List, Optional, Union
import ipyvuetify as v
import pygadm
import pygaul
import traitlets as t
from deprecated.sphinx import versionadded
from typing_extensions import Self
import sepal_ui.sepalwidgets as sw
from sepal_ui import mapping as sm
from sepal_ui.aoi.aoi_model import AoiModel
from sepal_ui.message import ms
from sepal_ui.scripts import decorator as sd
from sepal_ui.scripts import utils as su
CUSTOM = AoiModel.CUSTOM
ADMIN = AoiModel.ADMIN
ALL = "All"
select_methods = AoiModel.METHODS
__all__ = ["AoiView", "select_methods"]
[docs]
class MethodSelect(sw.Select):
[docs]
def __init__(
self,
methods: Union[str, List[str]] = "ALL",
gee: bool = True,
map_: Optional[sm.SepalMap] = None,
) -> None:
"""A method selector.
It will list the available methods for this very AoiView.
'ALL' will select all the available methods (default)
'ADMIN' only the admin one, 'CUSTOM' only the custom one.
'XXX' will add the selected method to the list when '-XXX' will discard it.
You cannot mix adding and removing behaviours.
Args:
methods: a list of methods from the available list (ADMIN0, ADMIN1, ADMIN2, SHAPE, DRAW, POINTS, ASSET)
map_: link the aoi_view to a custom SepalMap to display the output, default to None
gee: whether to bind to ee or not
"""
# create the method list
if methods == "ALL":
self.methods = select_methods
elif methods == "ADMIN":
self.methods = {
k: v for k, v in select_methods.items() if v["type"] == ADMIN
}
elif methods == "CUSTOM":
self.methods = {
k: v for k, v in select_methods.items() if v["type"] == CUSTOM
}
elif type(methods) == list:
if any(m[0] == "-" for m in methods) != all(m[0] == "-" for m in methods):
raise Exception("You mixed adding and removing, punk")
if methods[0][0] == "-":
to_remove = [method[1:] for method in methods]
# Rewrite the methods instead of mutate the class methods
self.methods = {
k: v for k, v in select_methods.items() if k not in to_remove
}
else:
self.methods = {k: select_methods[k] for k in methods}
else:
raise Exception("I don't get what you meant")
# clean the list from things we can't use
gee is True or self.methods.pop("ASSET", None)
map_ is not None or self.methods.pop("DRAW", None)
# build the item list with header
prev_type = None
items = []
for k, m in self.methods.items():
current_type = m["type"]
if prev_type != current_type:
items.append({"header": current_type})
prev_type = current_type
items.append({"text": m["name"], "value": k})
# create the input
super().__init__(label=ms.aoi_sel.method, items=items, v_model="", dense=True)
[docs]
class AdminField(sw.Select):
gee: bool = True
"whether or not to depend on earthengine"
level: int = -1
"The admin level of the current field"
parent: Optional[sw.Select] = None
"The parent adminfield object"
[docs]
def __init__(
self, level: int, parent: Optional[sw.Select] = None, gee: bool = True
) -> None:
"""An admin level selector.
It is binded to ee (GAUL 2015) or not (GADM). Allows to select administrative codes taking into account the administrative parent code and displaying humanly readable administrative names.
Args:
level: The administrative level of the field
parent: the adminField that deal with the parent admin level of the current selector. used to narrow down the possible options
ee: whether to use ee or not (default to True)
"""
# save ee state
self.gee = gee
# get the level info
self.level = level
self.parent = parent
# init an empty widget
super().__init__(
v_model=None, items=[], clearable=True, label=ms.aoi_sel.adm[level]
)
# add js behaviour
self.parent is None or self.parent.observe(self._update, "v_model")
[docs]
def show(self) -> Self:
"""when an admin field is shown, show its parent as well."""
super().show()
self.parent is None or self.parent.show()
return self
[docs]
def get_items(self, filter_: str = "") -> Self:
r"""Update the item list based on the given filter.
Args:
filter\_: The code of the parent v_model to filter the current results
"""
AdmNames = pygaul.AdmNames if self.gee else pygadm.AdmNames
df = AdmNames(admin=filter_, content_level=self.level)
df = df.sort_values(by=[df.columns[0]])
# formatted as a item list for a select component
# first column will be the name, second the code
items = []
for _, r in df.iterrows():
text = su.normalize_str(r.iloc[0], folder=False)
items.append({"text": text, "value": str(r.iloc[1])})
self.items = items
return self
def _update(self, change: dict) -> Self:
"""Update the item list of the admin select."""
# reset v_model
self.v_model = None
# update the items list
if change["new"]:
self.get_items(change["new"])
return self
[docs]
class AoiView(sw.Card):
# ##########################################################################
# ### widget parameters ###
# ##########################################################################
updated: t.Int = t.Int(0).tag(sync=True)
"Traitlets triggered every time a AOI is selected"
gee: bool = True
"Either or not he aoi_view is connected to gee"
folder: Union[str, Path] = ""
"The folder name used in GEE related component, mainly used for debugging"
model: Optional[AoiModel] = None
"The model to create the AOI from the selected parameters"
map_style: Optional[dict] = None
"The predefined style of the aoi on the map"
# ##########################################################################
# ### the embedded widgets ###
# ##########################################################################
map_: Optional[sm.SepalMap] = None
"The map to draw the AOI"
aoi_dc: Optional[sm.DrawControl] = None
"the drawing control associated with DRAW method"
w_method: Optional[MethodSelect] = None
"The widget to select the method"
components: Dict[str, v.VuetifyWidget] = {}
"The followingwidgets used to define AOI"
w_admin_0: Optional[AdminField] = None
"The widget used to select admin level 0"
w_admin_1: Optional[AdminField] = None
"The widget used to select admin level 1"
w_admin_2: Optional[AdminField] = None
"The widget used to select admin level 2"
w_vector: Optional[sw.VectorField] = None
"The widget used to select vector shapes"
w_points: Optional[sw.LoadTableField] = None
"The widget used to select points files"
w_draw: Optional[sw.TextField] = None
"The widget used to select the name of a drawn shape (only if :code:`map_ != None`)"
w_asset: Optional[sw.AssetSelect] = None
"The widget used to select asset name of a featureCollection (only if :code:`gee == True`)"
btn: Optional[sw.Btn] = None
"A default btn"
alert: Optional[sw.Alert] = None
"A alert to display message to the end user"
[docs]
@versionadded(
version="2.11.3",
reason="Model is now an optional parameter to AoiView, it can be created from outside and passed to the initialization function.",
)
def __init__(
self,
methods: Union[str, List[str]] = "ALL",
map_: Optional[sm.SepalMap] = None,
gee: bool = True,
folder: Union[str, Path] = "",
model: Optional[AoiModel] = None,
map_style: Optional[dict] = None,
**kwargs,
) -> None:
r"""Versatile card object to deal with the aoi selection.
multiple selection method are available (see the MethodSelector object) and the widget can be fully customizable. Can also be bound to ee (ee==True) or not (ee==False).
Args:
methods: the methods to use in the widget, default to 'ALL'. Available: {'ADMIN0', 'ADMIN1', 'ADMIN2', 'SHAPE', 'DRAW', 'POINTS', 'ASSET', 'ALL'}
map\_: link the aoi_view to a custom SepalMap to display the output, default to None
gee: whether to bind to ee or not
vector: the path to the default vector object
admin: the administrative code of the default selection. Need to be GADM if :code:`ee==False` and GAUL 2015 if :code:`ee==True`.
asset: the default asset. Can only work if :code:`ee==True`
map_style: the predefined style of the aoi. It's by default using a "success" ``sepal_ui.color`` with 0.5 transparent fill color. It can be completely replace by a fully qualified `style dictionary <https://ipyleaflet.readthedocs.io/en/latest/layers/geo_json.html>`__. Use the ``sepal_ui.color`` object to define any color to remain compatible with light and dark theme.
"""
# set ee dependency
self.gee = gee
self.folder = folder
if gee is True:
su.init_ee()
# get the model
self.model = model or AoiModel(gee=gee, folder=folder, **kwargs)
# get the map if filled
self.map_ = map_
# get the aoi geoJSON style
self.map_style = map_style
# create the method widget
self.w_method = MethodSelect(methods, gee=gee, map_=map_)
# add the methods blocks
self.w_admin_0 = AdminField(0, gee=gee).get_items()
self.w_admin_1 = AdminField(1, self.w_admin_0, gee=gee)
self.w_admin_2 = AdminField(2, self.w_admin_1, gee=gee)
self.w_vector = sw.VectorField(label=ms.aoi_sel.vector)
self.w_points = sw.LoadTableField(label=ms.aoi_sel.points)
# group them together with the same key as the select_method object
self.components = {
"ADMIN0": self.w_admin_0,
"ADMIN1": self.w_admin_1,
"ADMIN2": self.w_admin_2,
"SHAPE": self.w_vector,
"POINTS": self.w_points,
}
# hide them all
[c.hide() for c in self.components.values()]
# use the same alert as in the model
self.alert = sw.Alert()
# bind the widgets to the model
(
self.model.bind(self.w_admin_0, "admin")
.bind(self.w_admin_1, "admin")
.bind(self.w_admin_2, "admin")
.bind(self.w_vector, "vector_json")
.bind(self.w_points, "point_json")
.bind(self.w_method, "method")
)
# define the asset select separately. If no gee is set up we don't want any
# gee based widget to be requested. If it's the case, application that does not support GEE
# will crash if the user didn't authenticate
if self.gee:
self.w_asset = sw.VectorField(
label=ms.aoi_sel.asset, gee=True, folder=self.folder, types=["TABLE"]
)
self.w_asset.hide()
self.components["ASSET"] = self.w_asset
self.model.bind(self.w_asset, "asset_json")
# define DRAW option separately as it will only work if the map is set
if self.map_:
self.w_draw = sw.TextField(label=ms.aoi_sel.aoi_name).hide()
self.components["DRAW"] = self.w_draw
self.model.bind(self.w_draw, "name")
self.aoi_dc = sm.DrawControl(self.map_)
self.aoi_dc.hide()
# add a validation btn
self.btn = sw.Btn(msg=ms.aoi_sel.btn)
# create the widget
self.children = (
[self.w_method] + [*self.components.values()] + [self.btn, self.alert]
)
super().__init__(**kwargs)
# js events
self.w_method.observe(self._activate, "v_model") # activate widgets
self.btn.on_event("click", self._update_aoi) # load the information
# reset the aoi_model
self.model.clear_attributes()
@sd.loading_button()
def _update_aoi(self, *args) -> Self:
"""Load the object in the model & update the map (if possible)."""
# read the information from the geojson data
if self.map_:
self.model.geo_json = self.aoi_dc.to_json()
# update the model
self.model.set_object()
self.alert.add_msg(ms.aoi_sel.complete, "success")
# update the map
if self.map_:
self.map_.remove_layer("aoi", none_ok=True)
self.map_.zoom_bounds(self.model.total_bounds())
self.map_.add_layer(self.model.get_ipygeojson(self.map_style))
self.aoi_dc.hide()
# tell the rest of the apps that the aoi have been updated
self.updated += 1
return self
[docs]
def reset(self) -> Self:
"""Clear the aoi_model from input and remove the layer from the map (if existing)."""
# reset the view of the widgets
self.w_method.v_model = None
# clear the map
if self.map_ is not None:
self.map_.remove_layer("aoi", none_ok=True)
return self
@sd.switch("loading", on_widgets=["w_method"])
def _activate(self, change: dict) -> None:
"""Activate the adapted widgets."""
# clear and hide the alert
self.alert.reset()
# hide the widget so that the user doesn't see status changes
[w.hide() for w in self.components.values()]
# clear the inputs in a second step as resetting a FileInput can be long
[w.reset() for w in self.components.values()]
# deactivate or activate the dc
# clear the geo_json saved features to start from scratch
if self.map_:
if change["new"] == "DRAW":
self.aoi_dc.show()
else:
self.aoi_dc.hide()
# init the name to the current value
now = dt.now().strftime("%Y-%m-%d_%H-%M-%S")
self.w_draw.v_model = None if change["new"] is None else f"Manual_aoi_{now}"
# activate the correct widget
w = next((w for k, w in self.components.items() if k == change["new"]), None)
w is None or w.show()
return