Repository: 12rambau/sepal_ui

Issue:
add_legend feature
Now, as we got rid of the geemap library, one important and missing feature is `add_legend`, would be nice to get it back natively.

Discussion:

Patch:
diff --git a/.cz.yaml b/.cz.yaml
index 05742add..c2d69e89 100644
--- a/.cz.yaml
+++ b/.cz.yaml
@@ -3,7 +3,7 @@ commitizen:
   changelog_incremental: true
   tag_format: v_$major.$minor.$patch$prerelease
   update_changelog_on_bump: true
-  version: 2.10.3
+  version: 2.10.2
   version_files:
   - setup.py:version
   - sepal_ui/__init__.py:__version__
diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml
index ffeb0434..89744741 100644
--- a/.github/workflows/unit.yml
+++ b/.github/workflows/unit.yml
@@ -30,10 +30,6 @@ jobs:
           python -m pip install --upgrade pip
           pip install --find-links=https://girder.github.io/large_image_wheels --no-cache GDAL
           
-      - name: Install localetileserver
-        run: |
-          pip install localtileserver
-          
       - name: Install dependencies
         run: pip install .[test]
         
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab8a7927..fb338910 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,16 +1,3 @@
-## v_2.10.3 (2022-08-10)
-
-### Fix
-
-- lazy import localtileserver
-- avoid reloading root when fileinput is already none
-
-### Refactor
-
-- .. spelling:word-list::
-- reset method
-- remove legacy print
-
 ## v_2.10.2 (2022-07-28)
 
 ### Fix
diff --git a/sepal_ui/__init__.py b/sepal_ui/__init__.py
index 0f65dc57..64d44a80 100644
--- a/sepal_ui/__init__.py
+++ b/sepal_ui/__init__.py
@@ -5,7 +5,7 @@ from sepal_ui.frontend.styles import SepalColor, get_theme
 
 __author__ = """Pierrick Rambaud"""
 __email__ = "pierrick.rambaud49@gmail.com"
-__version__ = "2.10.3"
+__version__ = "2.10.2"
 
 color = SepalColor()
 'color: the colors of sepal. members are in the following list: "main, darker, bg, primary, accent, secondary, success, info, warning, error, menu". They will render according to the selected theme.'
diff --git a/sepal_ui/mapping/__init__.py b/sepal_ui/mapping/__init__.py
index 78835859..b178a82b 100644
--- a/sepal_ui/mapping/__init__.py
+++ b/sepal_ui/mapping/__init__.py
@@ -3,6 +3,7 @@ from .draw_control import *
 from .fullscreen_control import *
 from .layer import *
 from .layer_state_control import *
+from .legend_control import *
 from .map_btn import *
 from .menu_control import *
 from .sepal_map import *
diff --git a/sepal_ui/mapping/legend_control.py b/sepal_ui/mapping/legend_control.py
new file mode 100644
index 00000000..de367c3c
--- /dev/null
+++ b/sepal_ui/mapping/legend_control.py
@@ -0,0 +1,158 @@
+from ipyleaflet import WidgetControl
+from ipywidgets import HTML
+from traitlets import Bool, Dict, Unicode, observe
+
+import sepal_ui.sepalwidgets as sw
+from sepal_ui.message import ms
+from sepal_ui.scripts import utils as su
+
+
+class LegendControl(WidgetControl):
+    """
+    A custom Legend widget ready to be embed in a map
+
+    This Legend can be control though it's different attributes, changin it's position of course but also the orientation ,the keys and their colors.
+
+    .. versionadded:: 2.10.4
+
+    Args:
+        legend_dict (dict): the dictionnary to fill the legend values. cannot be empty.
+        title (str) title of the legend, if not set a default value in the current language will be used
+        vertical (bool): the orientation of the legend. default to True
+    """
+
+    title = Unicode(None).tag(sync=True)
+    "Unicode: title of the legend."
+
+    legend_dict = Dict(None).tag(sync=True)
+    "Dict: dictionary with key as label name and value as color"
+
+    vertical = Bool(None).tag(sync=True)
+    "Bool: whether to display the legend in a vertical or horizontal way"
+
+    _html_table = None
+
+    _html_title = None
+
+    def __init__(
+        self, legend_dict={}, title=ms.mapping.legend, vertical=True, **kwargs
+    ):
+
+        # init traits
+        self.title = title
+        self.legend_dict = legend_dict
+        self.vertical = vertical
+
+        # generate the content based on the init options
+        self._html_title = sw.Html(tag="h4", children=[f"{self.title}"])
+        self._html_table = sw.Html(tag="table", children=[])
+
+        # create a card inside the widget
+        # Be sure that the scroll bar will be shown up when legend horizontal
+        self.legend_card = sw.Card(
+            attributes={"id": "legend_card"},
+            style_="overflow-x:auto; white-space: nowrap;",
+            max_width=450,
+            max_height=350,
+            children=[self._html_title, self._html_table],
+        ).hide()
+
+        # set some parameters for the actual widget
+        kwargs["widget"] = self.legend_card
+        kwargs["position"] = kwargs.pop("position", "bottomright")
+
+        super().__init__(**kwargs)
+
+        self._set_legend(legend_dict)
+
+    def __len__(self):
+        """returns the number of elements in the legend"""
+
+        return len(self.legend_dict)
+
+    def hide(self):
+        """Hide control by hiding its content"""
+        self.legend_card.hide()
+
+    def show(self):
+        """Show control by displaying its content"""
+        self.legend_card.show()
+
+    @observe("legend_dict", "vertical")
+    def _set_legend(self, _):
+        """Creates/update a legend based on the class legend_dict member"""
+
+        # Do this to avoid crash when called by trait for the first time
+        if self._html_table is None:
+            return
+
+        if not self.legend_dict:
+            self.hide()
+            return
+
+        self.show()
+
+        if self.vertical:
+            elements = [
+                sw.Html(
+                    tag="tr" if self.vertical else "td",
+                    children=[
+                        sw.Html(tag="td", children=self.color_box(color)),
+                        label.capitalize(),
+                    ],
+                )
+                for label, color in self.legend_dict.items()
+            ]
+        else:
+            elements = [
+                (
+                    sw.Html(
+                        tag="td",
+                        children=[label.capitalize()],
+                    ),
+                    sw.Html(
+                        tag="td",
+                        children=self.color_box(color),
+                    ),
+                )
+                for label, color in self.legend_dict.items()
+            ]
+            # Flat nested list
+            elements = [e for row in elements for e in row]
+
+        self._html_table.children = elements
+
+        return
+
+    @observe("title")
+    def _update_title(self, change):
+        """Trait method to update the title of the legend"""
+
+        # Do this to avoid crash when called by trait
+        if self._html_title is None:
+            return
+
+        self._html_title.children = change["new"]
+
+        return
+
+    @staticmethod
+    def color_box(color, size=35):
+        """Returns an rectangular SVG html element with the provided color"""
+
+        # Define height and width based on the size
+        w = size
+        h = size / 2
+
+        return [
+            HTML(
+                f"""
+                    <th>
+                        <svg width='{w}' height='{h}'>
+                        <rect width='{w}' height='{h}' style='fill:{su.to_colors(color)};
+                        stroke-width:1;stroke:rgb(255,255,255)'/>
+                        </svg>
+                    </th>
+                """
+            )
+        ]
diff --git a/sepal_ui/mapping/sepal_map.py b/sepal_ui/mapping/sepal_map.py
index 6d2690ad..004c4360 100644
--- a/sepal_ui/mapping/sepal_map.py
+++ b/sepal_ui/mapping/sepal_map.py
@@ -33,6 +33,7 @@ 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.legend_control import LegendControl
 from sepal_ui.mapping.value_inspector import ValueInspector
 from sepal_ui.message import ms
 from sepal_ui.scripts import utils as su
@@ -859,6 +860,28 @@ class SepalMap(ipl.Map):
 
         return layer
 
+    def add_legend(
+        self,
+        title=ms.mapping.legend,
+        legend_dict={},
+        position="bottomright",
+        vertical=True,
+    ):
+        """
+        Creates and adds a custom legend as widget control to the map
+
+        Args:
+            title (str, optional): Title of the legend. Defaults to 'Legend'.
+            legend_dict (dict): dictionary with key as label name and value as color
+        """
+
+        # 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_control(self.legend)
+
     # ##########################################################################
     # ###                overwrite geemap calls                              ###
     # ##########################################################################
diff --git a/sepal_ui/message/en/locale.json b/sepal_ui/message/en/locale.json
index b689db70..247d226f 100644
--- a/sepal_ui/message/en/locale.json
+++ b/sepal_ui/message/en/locale.json
@@ -78,7 +78,8 @@
 		}
 	},
 	"mapping": {
-		"no_image": "The image file does not exist."
+		"no_image": "The image file does not exist.",
+        "legend" : "Legend"
 	},
     "planet" : {
         "exception" : {
diff --git a/setup.py b/setup.py
index 89750b43..f259be17 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,7 @@ from subprocess import check_call
 from setuptools import setup
 from setuptools.command.develop import develop
 
-version = "2.10.3"
+version = "2.10.2"
 
 DESCRIPTION = "Wrapper for ipyvuetify widgets to unify the display of voila dashboards in SEPAL platform"
 LONG_DESCRIPTION = open("README.rst").read()
@@ -69,6 +69,7 @@ setup_params = {
         "pyyaml",
         "dask",
         "tqdm",
+        "localtileserver",
         "jupyter-server-proxy",
         "matplotlib",
         "rioxarray",


Test patch:
diff --git a/tests/test_LegendControl.py b/tests/test_LegendControl.py
new file mode 100644
index 00000000..142cdce7
--- /dev/null
+++ b/tests/test_LegendControl.py
@@ -0,0 +1,114 @@
+import re
+
+from sepal_ui.mapping import LegendControl
+
+
+class TestLegend:
+    def test_init(self):
+
+        legend_dict = {
+            "forest": "#b3842e",
+            "non forest": "#a1458e",
+            "secondary": "#324a88",
+            "success": "#3f802a",
+            "info": "#79b1c9",
+            "warning": "#b8721d",
+        }
+
+        # hardcode expected
+        expected_labels = [
+            "Forest",
+            "Non forest",
+            "Secondary",
+            "Success",
+            "Info",
+            "Warning",
+        ]
+
+        legend = LegendControl(legend_dict, title="Legend")
+
+        # Check all the default values
+        assert legend.title == "Legend"
+        assert legend._html_title.children[0] == "Legend"
+        assert legend.legend_dict == legend_dict
+
+        # check all the labels and colors are present in the html
+        assert all([label in str(legend._html_table) for label in expected_labels])
+        assert all([color in str(legend._html_table) for color in legend_dict.values()])
+
+        # Check the lenght
+        assert len(legend) == 6
+
+        return
+
+    def test_set_legend(self):
+
+        legend_dict = {
+            "forest": "#b3842e",
+            "info": "#79b1c9",
+        }
+
+        legend = LegendControl(legend_dict)
+
+        new_legend = {
+            "forest": "#b3842e",
+            "non forest": "#3f802a",
+        }
+
+        # trigger the event
+        legend.legend_dict = new_legend
+
+        assert legend.legend_dict == new_legend
+
+        # Check that previous labels are not in the new legend
+        assert "Info" not in str(legend._html_table)
+
+        # check all the new labels are present in the legend
+        assert all(
+            [label in str(legend._html_table) for label in ["Forest", "Non forest"]]
+        )
+
+        # Act: change the view
+
+        # Check current view
+        assert legend.vertical is True
+
+        # in the vertical view, there should be at least two rows
+        assert str(legend._html_table).count("'tr'") == 2
+
+        legend.vertical = False
+        assert str(legend._html_table).count("'tr'") == 0
+
+        return
+
+    def test_update_title(self):
+
+        legend_dict = {
+            "forest": "#b3842e",
+            "info": "#79b1c9",
+        }
+
+        legend = LegendControl(legend_dict)
+
+        legend.title = "leyenda"
+
+        # Check all the default values
+        assert legend.title == "leyenda"
+        assert legend._html_title.children[0] == "leyenda"
+
+        return
+
+    def test_color_box(self):
+
+        legend_dict = {
+            "forest": "#b3842e",
+            "info": "#79b1c9",
+        }
+        legend = LegendControl(legend_dict)
+        str_box = re.sub("[ ]+", "", str(legend.color_box("blue", 50)[0]))
+
+        assert "fill:#0000ff" in str_box
+        assert "width='50'" in str_box
+        assert "'height='25.0'" in str_box
+
+        return
diff --git a/tests/test_SepalMap.py b/tests/test_SepalMap.py
index 2ba4da45..7c4f1dc4 100644
--- a/tests/test_SepalMap.py
+++ b/tests/test_SepalMap.py
@@ -11,6 +11,7 @@ from ipyleaflet import GeoJSON
 from sepal_ui import get_theme
 from sepal_ui import mapping as sm
 from sepal_ui.frontend import styles as ss
+from sepal_ui.mapping.legend_control import LegendControl
 
 # create a seed so that we can check values
 random.seed(42)
@@ -387,6 +388,25 @@ class TestSepalMap:
 
         return
 
+    def test_add_legend(self, ee_map_with_layers):
+
+        legend_dict = {
+            "forest": "#b3842e",
+            "non forest": "#a1458e",
+            "secondary": "#324a88",
+            "success": "#3f802a",
+            "info": "#79b1c9",
+            "warning": "#b8721d",
+        }
+
+        ee_map_with_layers.add_legend(legend_dict=legend_dict)
+
+        # just test that is a Legend, the rest is tested by Legend
+        assert isinstance(ee_map_with_layers.legend, LegendControl)
+        assert ee_map_with_layers.legend.legend_dict == legend_dict
+
+        return
+
     @pytest.fixture
     def rgb(self):
         """add a raster file of the bahamas coming from rasterio test suit"""