Repository: 12rambau/sepal_ui

Issue:
display a loading statebar when a layer is loading in the map 
## issue 
I experienced some issues with the FCDM module as the map is super slow to load (it uses a adaptative buffer, the more you zoom the slower it gets it's a nightmare). The consequence is that the user clicks frantically on the `zoom` `unzoom` btn and the layer never fully loads and they eventually break the app.

I've looked into the loading widgets that exists in ipyleaflet and that's not very elegant (from my perspective).

## current implementation

I came up with the idea of overwriting the `add_layer` method to add a `StateBar` at the top left corner for every layer and dynamically show them when the tile are reloading. overwritting the `add_layer` method is super versatile as it's the basic function called by all the others (`add_ee_layer`, `addLayer`, `add_raster_layer` ... etc). 

- user doesn't change anything in the way object are added to the map
- each time you add a layer, it adds a statebar writting "loading \<whatever\>"
- the statebare is shown whenever the layer is loading (thank you traitlets)

## questions

- should we remove them when the layer is removed (overwritting remove_layer as well) ?
- should the statebar always be displayed (and instead of changing viz, we could change loading trait) ?
- do you think it could be useful in general ? 

## demo 

Here is a gif demo and the the code I used. 


![feature_demo](https://user-images.githubusercontent.com/12596392/142245503-4b78f635-9700-468c-9202-fa04a13de4b8.gif)

```python
from sepal_ui.mapping import SepalMap
from sepal_ui import sepalwidgets as sw 
from ipyleaflet import WidgetControl
import ee

ee.Initialize()

# create a custom statebar
class StateBar(sw.StateBar):
    
    def __init__(self, **kwargs):
        
        name = kwargs.pop("layer", "layer")
        kwargs["_metadata"] = {"layer": name} 
        
        super().__init__(**kwargs)
        
        self.msg = f"Loading {name}"
        self.loading = True
        
    def activate(self, change):
        
        if change['new']:
            self.show()
        else:
            self.hide()
            
        return

# define a custom Map class
class Map(SepalMap):
    
    layer_state_list = []
    
    def add_layer(self, l):
        
        # call the original function
        super().add_layer(l)
        
        # add a layer state object 
        state = StateBar(layer=l.name)
        self.layer_state_list += [state]
        self.add_control(WidgetControl(widget=state, position='topleft'))
        
        # link it to the layer state
        #layer = next(l for l in self.layers if l.name == name)
        l.observe(state.activate, "loading")
        
        return

# create the map and zoom on congo
test_map = Map()
test_map.zoom = 10
test_map.center = [5.703447982149503, 28.32275390625] 

# load hansen & al ddataset
dataset = ee.Image('UMD/hansen/global_forest_change_2020_v1_8')
treeCoverVisParam = {
  "bands": ['treecover2000'],
  "min": 0,
  "max": 100,
  "palette": ['black', 'green']
}
test_map.addLayer(dataset, treeCoverVisParam, 'tree cover')

# load the S2 RGB product (SR)
def maskS2clouds(image):

    qa = image.select('QA60');
    
    # Bits 10 and 11 are clouds and cirrus, respectively.
    cloudBitMask = 1 << 10;
    cirrusBitMask = 1 << 11;

    # Both flags should be set to zero, indicating clear conditions.
    mask = (
        qa
        .bitwiseAnd(cloudBitMask).eq(0)
        .And(qa.bitwiseAnd(cirrusBitMask).eq(0))
    )

    return image.updateMask(mask).divide(10000)

dataset = (
    ee.ImageCollection('COPERNICUS/S2_SR')
    .filterDate('2020-01-01', '2020-01-30')
    .filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE',20))
    .map(maskS2clouds)
)

visualization = {
  "min": 0.0,
  "max": 0.3,
  "bands": ['B4', 'B3', 'B2'],
}

test_map.addLayer(dataset.mean(), visualization, 'RGB')

# display the map
test_map
```


Discussion:

Patch:
diff --git a/sepal_ui/mapping/__init__.py b/sepal_ui/mapping/__init__.py
index 7f72a0eb..aa84ddee 100644
--- a/sepal_ui/mapping/__init__.py
+++ b/sepal_ui/mapping/__init__.py
@@ -2,6 +2,7 @@ from .aoi_control import *
 from .draw_control import *
 from .fullscreen_control import *
 from .layer import *
+from .layer_state_control import *
 from .map_btn import *
 from .sepal_map import *
 from .value_inspector import *
diff --git a/sepal_ui/mapping/layer_state_control.py b/sepal_ui/mapping/layer_state_control.py
new file mode 100644
index 00000000..eff80080
--- /dev/null
+++ b/sepal_ui/mapping/layer_state_control.py
@@ -0,0 +1,99 @@
+from traitlets import Int, observe
+from ipyleaflet import WidgetControl
+
+from sepal_ui import sepalwidgets as sw
+from sepal_ui.message import ms
+
+
+class LayerStateControl(WidgetControl):
+    """
+    A specific statebar dedicated to the the counting of loading tiles in the map
+
+    every time a map is added to the map the counter will be raised by one. same behaviour with removed.
+    """
+
+    m = None
+    "SepalMap: the map connected to the control"
+
+    w_state = None
+    "sw.StateBar: the stateBar displaying the number of layer loading on the map"
+
+    nb_layer = Int(0).tag(sync=True)
+    "Int: the number of layers in the map"
+
+    nb_loading_layer = Int(0).tag(sync=True)
+    "Int: the number of loading layer in the map"
+
+    def __init__(self, m, **kwargs):
+
+        # save the map as a member of the widget
+        self.m = m
+
+        # create a statebar
+        msg = ms.layer_state.complete.format(self.nb_layer)
+        self.w_state = sw.StateBar(loading=False, msg=msg)
+
+        # overwrite the widget set in the kwargs (if any)
+        kwargs["widget"] = self.w_state
+        kwargs["position"] = kwargs.pop("position", "topleft")
+        kwargs["transparent_bg"] = True
+
+        # create the widget
+        super().__init__(**kwargs)
+
+        # add js behaviour
+        self.m.observe(self.update_nb_layer, "layers")
+
+    def update_nb_layer(self, change):
+        """
+        Update the number of layer monitored by the statebar
+        """
+
+        # exit if nothing changed
+        # for example we change a layer parameters and it trigger this one
+        if len(change["new"]) == len(change["old"]):
+            return
+
+        self.nb_layer = len([lyr for lyr in change["new"] if not lyr.base])
+
+        # identify the modified layer
+        modified_layer = list(set(change["new"]) ^ set(change["old"]))[0]
+        if modified_layer.base is True:
+            return
+
+        # add a layer
+        if len(change["new"]) > len(change["old"]):
+            modified_layer.observe(self.update_loading, "loading")
+
+        # remove a layer
+        elif len(change["new"]) < len(change["old"]):
+            # the test is splitted as not all the layers have a loading trait
+            if hasattr(modified_layer, "loading") is True:
+                if modified_layer.loading is True:
+                    self.nb_loading_layer += -1
+
+        return
+
+    def update_loading(self, change):
+        """update the nb_loading_layer value according to the number of tile loading on the map"""
+
+        increment = [-1, 1]
+        self.nb_loading_layer += increment[change["new"]]
+
+        return
+
+    @observe("nb_loading_layer", "nb_layer")
+    def _update_state(self, change):
+
+        # check if anything is loading
+        self.loading = bool(self.nb_loading_layer)
+
+        # update the message
+        if self.loading is True:
+            msg = ms.layer_state.loading.format(self.nb_loading_layer, self.nb_layer)
+        else:
+            msg = ms.layer_state.complete.format(self.nb_layer)
+
+        self.w_state.msg = msg
+
+        return
diff --git a/sepal_ui/mapping/sepal_map.py b/sepal_ui/mapping/sepal_map.py
index e2860daf..4ef41d1a 100644
--- a/sepal_ui/mapping/sepal_map.py
+++ b/sepal_ui/mapping/sepal_map.py
@@ -31,6 +31,7 @@ import sepal_ui.frontend.styles as styles
 from sepal_ui.mapping.basemaps import basemap_tiles
 from sepal_ui.mapping.draw_control import DrawControl
 from sepal_ui.mapping.layer import EELayer
+from sepal_ui.mapping.layer_state_control import LayerStateControl
 from sepal_ui.mapping.value_inspector import ValueInspector
 from sepal_ui.message import ms
 from sepal_ui.scripts import utils as su
@@ -55,6 +56,7 @@ class SepalMap(ipl.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
+        statebar (bool): wether or not to display the Statebar in the map
         kwargs (optional): any parameter from a ipyleaflet.Map. if set, 'ee_initialize' will be overwritten.
     """
 
@@ -74,7 +76,18 @@ class SepalMap(ipl.Map):
     _id = None
     "str: a unique 6 letters str to identify the map in the DOM"
 
-    def __init__(self, basemaps=[], dc=False, vinspector=False, gee=True, **kwargs):
+    state = None
+    "sw.StateBar: the statebar to inform the user about tile loading"
+
+    def __init__(
+        self,
+        basemaps=[],
+        dc=False,
+        vinspector=False,
+        gee=True,
+        statebar=False,
+        **kwargs,
+    ):
 
         # set the default parameters
         kwargs["center"] = kwargs.pop("center", [0, 0])
@@ -114,6 +127,10 @@ class SepalMap(ipl.Map):
         self.v_inspector = ValueInspector(self)
         not vinspector or self.add_control(self.v_inspector)
 
+        # specific statebar
+        self.state = LayerStateControl(self)
+        not statebar or self.add_control(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))
diff --git a/sepal_ui/message/en/layer_control.json b/sepal_ui/message/en/layer_control.json
new file mode 100644
index 00000000..2f90d41c
--- /dev/null
+++ b/sepal_ui/message/en/layer_control.json
@@ -0,0 +1,6 @@
+{
+    "layer_state": {
+        "loading": "loading {} layer(s) out of {}",
+        "complete": "{} layer(s) loaded"
+    }
+}
\ No newline at end of file
diff --git a/sepal_ui/sepalwidgets/alert.py b/sepal_ui/sepalwidgets/alert.py
index e143170e..6ad4d5d0 100644
--- a/sepal_ui/sepalwidgets/alert.py
+++ b/sepal_ui/sepalwidgets/alert.py
@@ -250,7 +250,7 @@ class Alert(v.Alert, SepalWidget):
         return su.check_input(input_, msg)
 
 
-class StateBar(v.SystemBar):
+class StateBar(v.SystemBar, SepalWidget):
 
     """Widget to display quick messages on simple inline status bar
 


Test patch:
diff --git a/tests/test_LayerStateControl.py b/tests/test_LayerStateControl.py
new file mode 100644
index 00000000..ce2aecb2
--- /dev/null
+++ b/tests/test_LayerStateControl.py
@@ -0,0 +1,84 @@
+from ipyleaflet import RasterLayer
+from traitlets import Bool
+import ee
+import pytest
+
+from sepal_ui import mapping as sm
+from sepal_ui.scripts import utils as su
+
+
+class TestLayerStateControl:
+    def test_init(self):
+
+        m = sm.SepalMap()
+        state = sm.LayerStateControl(m)
+        m.add_control(state)
+
+        assert isinstance(state, sm.LayerStateControl)
+        assert state.w_state.loading is False
+
+        return
+
+    @su.need_ee
+    def test_update_nb_layer(self, map_with_layers):
+
+        # create the map and controls
+        m = map_with_layers
+        state = next(c for c in m.controls if isinstance(c, sm.LayerStateControl))
+
+        # TODO I don't know how to check state changes but I can at least check the conclusion
+        assert state.w_state.msg == "2 layer(s) loaded"
+
+        # remove a layer to update the nb_layer
+        m.remove_layer(-1)
+        assert state.w_state.msg == "1 layer(s) loaded"
+
+        return
+
+    def test_update_loading(self, map_with_layers):
+
+        # get the map and control
+        m = map_with_layers
+        state = next(c for c in m.controls if isinstance(c, sm.LayerStateControl))
+
+        # check that the parameter is updated with existing layers
+        m.layers[-1].loading = True
+        assert state.nb_loading_layer == 1
+        assert state.w_state.msg == "loading 1 layer(s) out of 2"
+
+        # check when this loading layer is removed
+        m.remove_layer(-1)
+        assert state.nb_loading_layer == 0
+        assert state.w_state.msg == "1 layer(s) loaded"
+
+        return
+
+    @pytest.fixture
+    def map_with_layers(self, fake_layer):
+        """create a map with 2 layers and a stateBar"""
+
+        # create the map and controls
+        m = sm.SepalMap()
+        state = sm.LayerStateControl(m)
+        m.add_control(state)
+
+        # add some ee_layer (loading very fast)
+        # world lights
+        dataset = ee.ImageCollection("NOAA/DMSP-OLS/CALIBRATED_LIGHTS_V4").filter(
+            ee.Filter.date("2010-01-01", "2010-12-31")
+        )
+        m.addLayer(dataset, {}, "Nighttime Lights")
+
+        # a fake layer with loading update possibilities
+        m.add_layer(fake_layer)
+
+        return m
+
+    @pytest.fixture
+    def fake_layer(self):
+        """create a layer from a fakelayer class that have only one parameter: the laoding trait"""
+
+        class FakeLayer(RasterLayer):
+            loading = Bool(False).tag(sync=True)
+
+        return FakeLayer()
diff --git a/tests/test_SepalMap.py b/tests/test_SepalMap.py
index 51f5a01a..30a0d3eb 100644
--- a/tests/test_SepalMap.py
+++ b/tests/test_SepalMap.py
@@ -51,6 +51,10 @@ class TestSepalMap:
         m = sm.SepalMap(vinspector=True)
         assert m.v_inspector in m.controls
 
+        # check that the map start with a statebar
+        m = sm.SepalMap(statebar=True)
+        assert m.state 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):
             m = sm.SepalMap(["TOTO"])
diff --git a/tests/test_StateBar.py b/tests/test_StateBar.py
index 9eb3ab0f..0ed4ed5c 100644
--- a/tests/test_StateBar.py
+++ b/tests/test_StateBar.py
@@ -7,6 +7,7 @@ class TestStateBar:
         # minimal state bar
         state_bar = sw.StateBar()
         assert len(state_bar.children) == 2
+        assert state_bar.viz is True
 
         return