From d1c3a0d39e649d48ca4b73e0831ab7c9b510bd97 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Fri, 8 Mar 2024 15:03:15 +0100 Subject: [PATCH] Re: #1606 Add TreeLayerControl to Folium --- .gitignore | 1 + docs/user_guide/plugins/realtime.md | 2 +- docs/user_guide/plugins/treelayercontrol.md | 75 +++++++++ folium/plugins/__init__.py | 2 + folium/plugins/treelayercontrol.py | 163 ++++++++++++++++++++ folium/template.py | 51 ++++++ tests/test_template.py | 30 ++++ 7 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 docs/user_guide/plugins/treelayercontrol.md create mode 100644 folium/plugins/treelayercontrol.py create mode 100644 folium/template.py create mode 100644 tests/test_template.py diff --git a/.gitignore b/.gitignore index b6d9f94ad..2b895d782 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dist/ docs/index.html docs/_build/ docs/quickstart.ipynb +docs/**/*.ipynb examples/results/* .cache/ .idea/ diff --git a/docs/user_guide/plugins/realtime.md b/docs/user_guide/plugins/realtime.md index 8658851ab..cc8c85747 100644 --- a/docs/user_guide/plugins/realtime.md +++ b/docs/user_guide/plugins/realtime.md @@ -6,7 +6,7 @@ import folium import folium.plugins ``` -# Realtime plugin +# Realtime Put realtime data on a Leaflet map: live tracking GPS units, sensor data or just about anything. diff --git a/docs/user_guide/plugins/treelayercontrol.md b/docs/user_guide/plugins/treelayercontrol.md new file mode 100644 index 000000000..44719d844 --- /dev/null +++ b/docs/user_guide/plugins/treelayercontrol.md @@ -0,0 +1,75 @@ +```{code-cell} ipython3 +--- +nbsphinx: hidden +--- +import folium +import folium.plugins +``` + +# TreeLayerControl +Create a Layer Control allowing a tree structure for the layers. + +See https://github.com/jjimenezshaw/Leaflet.Control.Layers.Tree for more +information. + +## Simple example + +```{code-cell} ipython3 +import folium +from folium.plugins.treelayercontrol import TreeLayerControl +from folium.features import Marker + +m = folium.Map(location=[46.603354, 1.8883335], zoom_start=5) +osm = folium.TileLayer("openstreetmap").add_to(m) + +overlay_tree = { + "label": "Points of Interest", + "select_all_checkbox": "Un/select all", + "children": [ + { + "label": "Europe", + "select_all_checkbox": True, + "children": [ + { + "label": "France", + "select_all_checkbox": True, + "children": [ + { "label": "Tour Eiffel", "layer": Marker([48.8582441, 2.2944775]).add_to(m) }, + { "label": "Notre Dame", "layer": Marker([48.8529540, 2.3498726]).add_to(m) }, + { "label": "Louvre", "layer": Marker([48.8605847, 2.3376267]).add_to(m) }, + ] + }, { + "label": "Germany", + "select_all_checkbox": True, + "children": [ + { "label": "Branderburger Tor", "layer": Marker([52.5162542, 13.3776805]).add_to(m)}, + { "label": "Kölner Dom", "layer": Marker([50.9413240, 6.9581201]).add_to(m)}, + ] + }, {"label": "Spain", + "select_all_checkbox": "De/seleccionar todo", + "children": [ + { "label": "Palacio Real", "layer": Marker([40.4184145, -3.7137051]).add_to(m)}, + { "label": "La Alhambra", "layer": Marker([37.1767829, -3.5892795]).add_to(m)}, + ] + } + ] + }, { + "label": "Asia", + "select_all_checkbox": True, + "children": [ + { + "label": "Jordan", + "select_all_checkbox": True, + "children": [ + { "label": "Petra", "layer": Marker([30.3292215, 35.4432464]).add_to(m) }, + { "label": "Wadi Rum", "layer": Marker([29.6233486, 35.4390656]).add_to(m) } + ] + }, { + } + ] + } + ] +} + +control = TreeLayerControl(overlay_tree=overlay_tree).add_to(m) +``` diff --git a/folium/plugins/__init__.py b/folium/plugins/__init__.py index 93d2bfc14..3c126f3f3 100644 --- a/folium/plugins/__init__.py +++ b/folium/plugins/__init__.py @@ -31,6 +31,7 @@ from folium.plugins.time_slider_choropleth import TimeSliderChoropleth from folium.plugins.timestamped_geo_json import TimestampedGeoJson from folium.plugins.timestamped_wmstilelayer import TimestampedWmsTileLayers +from folium.plugins.treelayercontrol import TreeLayerControl from folium.plugins.vectorgrid_protobuf import VectorGridProtobuf __all__ = [ @@ -66,5 +67,6 @@ "TimeSliderChoropleth", "TimestampedGeoJson", "TimestampedWmsTileLayers", + "TreeLayerControl", "VectorGridProtobuf", ] diff --git a/folium/plugins/treelayercontrol.py b/folium/plugins/treelayercontrol.py new file mode 100644 index 000000000..ff1af6994 --- /dev/null +++ b/folium/plugins/treelayercontrol.py @@ -0,0 +1,163 @@ +from typing import Union + +from branca.element import MacroElement + +from folium.elements import JSCSSMixin +from folium.template import Template +from folium.utilities import parse_options + + +class TreeLayerControl(JSCSSMixin, MacroElement): + """ + Create a Layer Control allowing a tree structure for the layers. + See https://github.com/jjimenezshaw/Leaflet.Control.Layers.Tree for more + information. + + Parameters + ---------- + base_tree : dict + A dictionary defining the base layers. + Valid elements are + + children: list + Array of child nodes for this node. Each node is a dict that has the same valid elements as base_tree. + label: str + Text displayed in the tree for this node. It may contain HTML code. + layer: Layer + The layer itself. This needs to be added to the map. + name: str + Text displayed in the toggle when control is minimized. + If not present, label is used. It makes sense only when + namedToggle is true, and with base layers. + radioGroup: str, default '' + Text to identify different radio button groups. + It is used in the name attribute in the radio button. + It is used only in the overlays layers (ignored in the base + layers), allowing you to have radio buttons instead of checkboxes. + See that radio groups cannot be unselected, so create a 'fake' + layer (like L.layersGroup([])) if you want to disable it. + Default '' (that means checkbox). + collapsed: bool, default False + Indicate whether this tree node should be collapsed initially, + useful for opening large trees partially based on user input or + context. + selectAllCheckbox: bool or str + Displays a checkbox to select/unselect all overlays in the + sub-tree. In case of being a , that text will be the title + (tooltip). When any overlay in the sub-tree is clicked, the + checkbox goes into indeterminate state (a dash in the box). + overlay_tree: dict + Similar to baseTree, but for overlays. + closed_symbol: str, default '+', + Symbol displayed on a closed node (that you can click to open). + opened_symbol: str, default '-', + Symbol displayed on an opened node (that you can click to close). + space_symbol: str, default ' ', + Symbol between the closed or opened symbol, and the text. + selector_back: bool, default False, + Flag to indicate if the selector (+ or −) is after the text. + named_toggle: bool, default False, + Flag to replace the toggle image (box with the layers image) with the + 'name' of the selected base layer. If the name field is not present in + the tree for this layer, label is used. See that you can show a + different name when control is collapsed than the one that appears + in the tree when it is expanded. + collapse_all: str, default '', + Text for an entry in control that collapses the tree (baselayers or + overlays). If empty, no entry is created. + expand_all: str, default '', + Text for an entry in control that expands the tree. If empty, no entry + is created + label_is_selector: str, default 'both', + Controls if a label or only the checkbox/radiobutton can toggle layers. + If set to `both`, `overlay` or `base` those labels can be clicked + on to toggle the layer. + **kwargs + Additional (possibly inherited) options. See + https://leafletjs.com/reference.html#control-layers + + Examples + -------- + >>> import folium + >>> from folium.plugins.treelayercontrol import TreeLayerControl + >>> from folium.features import Marker + + >>> m = folium.Map(location=[46.603354, 1.8883335], zoom_start=5) + + >>> marker = Marker([48.8582441, 2.2944775]).add_to(m) + + >>> overlay_tree = { + ... "label": "Points of Interest", + ... "selectAllCheckbox": "Un/select all", + ... "children": [ + ... { + ... "label": "Europe", + ... "selectAllCheckbox": True, + ... "children": [ + ... { + ... "label": "France", + ... "selectAllCheckbox": True, + ... "children": [ + ... {"label": "Tour Eiffel", "layer": marker}, + ... ], + ... } + ... ], + ... } + ... ], + ... } + + >>> control = TreeLayerControl(overlay_tree=overlay_tree).add_to(m) + """ + + default_js = [ + ( + "L.Control.Layers.Tree.min.js", + "https://cdn.jsdelivr.net/npm/leaflet.control.layers.tree@1.1.0/L.Control.Layers.Tree.min.js", # noqa + ), + ] + default_css = [ + ( + "L.Control.Layers.Tree.min.css", + "https://cdn.jsdelivr.net/npm/leaflet.control.layers.tree@1.1.0/L.Control.Layers.Tree.min.css", # noqa + ) + ] + + _template = Template( + """ + {% macro script(this,kwargs) %} + L.control.layers.tree( + {{this.base_tree|tojavascript}}, + {{this.overlay_tree|tojavascript}}, + {{this.options|tojson}} + ).addTo({{this._parent.get_name()}}); + {% endmacro %} + """ + ) + + def __init__( + self, + base_tree: Union[dict, list, None] = None, + overlay_tree: Union[dict, list, None] = None, + closed_symbol: str = "+", + opened_symbol: str = "-", + space_symbol: str = " ", + selector_back: bool = False, + named_toggle: bool = False, + collapse_all: str = "", + expand_all: str = "", + label_is_selector: str = "both", + **kwargs + ): + super().__init__() + self._name = "TreeLayerControl" + kwargs["closed_symbol"] = closed_symbol + kwargs["openened_symbol"] = opened_symbol + kwargs["space_symbol"] = space_symbol + kwargs["selector_back"] = selector_back + kwargs["named_toggle"] = named_toggle + kwargs["collapse_all"] = collapse_all + kwargs["expand_all"] = expand_all + kwargs["label_is_selector"] = label_is_selector + self.options = parse_options(**kwargs) + self.base_tree = base_tree + self.overlay_tree = overlay_tree diff --git a/folium/template.py b/folium/template.py new file mode 100644 index 000000000..f8ebfc952 --- /dev/null +++ b/folium/template.py @@ -0,0 +1,51 @@ +import json +from typing import Union + +import jinja2 +from branca.element import Element + +from folium.utilities import JsCode, TypeJsonValue, camelize + + +def tojavascript(obj: Union[str, JsCode, dict, list]) -> str: + if isinstance(obj, JsCode): + return obj.js_code + elif isinstance(obj, Element): + return obj.get_name() + elif isinstance(obj, dict): + out = ["{\n"] + for key, value in obj.items(): + out.append(f' "{camelize(key)}": ') + out.append(tojavascript(value)) + out.append(",\n") + out.append("}") + return "".join(out) + elif isinstance(obj, list): + out = ["[\n"] + for value in obj: + out.append(tojavascript(value)) + out.append(",\n") + out.append("]") + return "".join(out) + else: + return _to_escaped_json(obj) + + +def _to_escaped_json(obj: TypeJsonValue) -> str: + return ( + json.dumps(obj) + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026") + .replace("'", "\\u0027") + ) + + +class Environment(jinja2.Environment): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.filters["tojavascript"] = tojavascript + + +class Template(jinja2.Template): + environment_class = Environment diff --git a/tests/test_template.py b/tests/test_template.py new file mode 100644 index 000000000..5c7753ee0 --- /dev/null +++ b/tests/test_template.py @@ -0,0 +1,30 @@ +import folium +from folium.template import tojavascript +from folium.utilities import JsCode + + +def test_tojavascript(): + trail_coordinates = [ + (-71.351871840295871, -73.655963711222626), + (-71.374144382613707, -73.719861619751498), + (-71.391042575973145, -73.784922248007007), + (-71.400964450973134, -73.851042243124397), + (-71.402411391077322, -74.050048183880477), + ] + + trail = folium.PolyLine(trail_coordinates, tooltip="Coast") + d = { + "label": "Base Layers", + "children": [ + { + "label": "World 🗺", + "children": [ + {"label": "trail", "layer": trail}, + {"jscode": JsCode('function(){return "hi"}')}, + ], + } + ], + } + js = tojavascript(d) + assert "poly_line" in js + assert 'return "hi"' in js