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/folium.py b/folium/folium.py index a61927034..6a4ef3f77 100644 --- a/folium/folium.py +++ b/folium/folium.py @@ -45,7 +45,7 @@ # glyphicons came from Bootstrap 3 and are used for Awesome Markers ( "glyphicons_css", - "https://netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css", + "https://netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap-glyphicons.css", ), ( "awesome_markers_font_css", 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..4dcd01f0b --- /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, Element]) -> 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..c91c75ac9 --- /dev/null +++ b/tests/test_template.py @@ -0,0 +1,68 @@ +from branca.element import Element + +from folium import JsCode +from folium.template import Environment, Template, _to_escaped_json, tojavascript + + +def test_tojavascript_with_jscode(): + js_code = JsCode("console.log('Hello, World!')") + assert tojavascript(js_code) == "console.log('Hello, World!')" + + +def test_tojavascript_with_element(): + element = Element() + assert tojavascript(element) == element.get_name() + + +def test_tojavascript_with_dict(): + dict_obj = {"key": "value"} + assert tojavascript(dict_obj) == '{\n "key": "value",\n}' + + +def test_tojavascript_with_list(): + list_obj = ["value1", "value2"] + assert tojavascript(list_obj) == '[\n"value1",\n"value2",\n]' + + +def test_tojavascript_with_string(): + assert tojavascript("Hello, World!") == _to_escaped_json("Hello, World!") + + +def test_tojavascript_with_combined_elements(): + js_code = JsCode("console.log('Hello, World!')") + element = Element() + combined_dict = { + "key": "value", + "list": ["value1", "value2", element, js_code], + "nested_dict": {"nested_key": "nested_value"}, + } + result = tojavascript(combined_dict) + expected_lines = [ + "{", + ' "key": "value",', + ' "list": [', + '"value1",', + '"value2",', + element.get_name() + ",", + "console.log('Hello, World!'),", + "],", + ' "nestedDict": {', + ' "nestedKey": "nested_value",', + "},", + "}", + ] + for result_line, expected_line in zip(result.splitlines(), expected_lines): + assert result_line == expected_line + + +def test_to_escaped_json(): + assert _to_escaped_json("hi<>&'") == '"hi\\u003c\\u003e\\u0026\\u0027"' + + +def test_environment_filter(): + env = Environment() + assert "tojavascript" in env.filters + + +def test_template_environment_class(): + assert Template.environment_class == Environment