diff --git a/python/grass/jupyter/interactivemap.py b/python/grass/jupyter/interactivemap.py index 0813dc59646..a146f32e4f5 100644 --- a/python/grass/jupyter/interactivemap.py +++ b/python/grass/jupyter/interactivemap.py @@ -142,6 +142,35 @@ def add_to(self, interactive_map): interactive_map.add(image) +# Leaflet path options for the styling d.vect exposes. The names on the left +# are the ones a GRASS user already knows from d.vect, the ones on the right +# are what both backends pass to Leaflet. +VECTOR_STYLE_OPTIONS = { + "color": "color", + "fill_color": "fillColor", + "width": "weight", + "opacity": "opacity", + "fill_opacity": "fillOpacity", +} + + +def vector_style(options): + """Translate GRASS vector style options to Leaflet path options + + Takes the recognized options out of *options* and returns them as a + dictionary Leaflet understands. Options which are not about style are + left in place, so what remains can go to the backend unchanged. + + :param dict options: keyword arguments given to a vector layer + :return dict: Leaflet path options, empty if none were given + """ + return { + leaflet_name: options.pop(name) + for name, leaflet_name in VECTOR_STYLE_OPTIONS.items() + if name in options + } + + class Vector(Layer): """Adds vectors to a folium or ipyleaflet map. @@ -172,9 +201,26 @@ def __init__( def add_to(self, interactive_map): """Add vector to map""" + # The two backends take styling differently, folium through a + # callable and ipyleaflet through a dictionary, so the same options + # are translated once here and handed to each in its own form. + style = vector_style(self._layer_kwargs) if get_backend(interactive_map) == "folium": import folium # pylint: disable=import-outside-toplevel + if style: + # Both backends apply the named options on top of whatever the + # backend's own form produces, so the same call means the same + # thing on either one. A style_function is a callable, so it is + # wrapped rather than merged. + given = self._layer_kwargs.get("style_function") + if given is None: + self._layer_kwargs["style_function"] = lambda _feature: style + else: + self._layer_kwargs["style_function"] = lambda feature: { + **given(feature), + **style, + } folium.GeoJson( str(self._filename), name=self._title, **self._layer_kwargs ).add_to(interactive_map) @@ -183,14 +229,14 @@ def add_to(self, interactive_map): with open(self._filename, encoding="utf-8") as file: data = json.load(file) - # allow using opacity directly to keep interface - # consistent for both backends - if "opacity" in self._layer_kwargs: - opacity = self._layer_kwargs.pop("opacity") - if "style" in self._layer_kwargs: - self._layer_kwargs["style"]["opacity"] = opacity - else: - self._layer_kwargs["style"] = {"opacity": opacity} + if style: + # `or {}` because passing style=None is how a caller asks for + # the backend default, and a missing key and an empty value + # have to mean the same thing here. + self._layer_kwargs["style"] = { + **(self._layer_kwargs.get("style") or {}), + **style, + } geo_json = ipyleaflet.GeoJSON( data=data, name=self._title, **self._layer_kwargs ) diff --git a/python/grass/jupyter/tests/interactivemap_vector_style_test.py b/python/grass/jupyter/tests/interactivemap_vector_style_test.py new file mode 100644 index 00000000000..8659d6ea257 --- /dev/null +++ b/python/grass/jupyter/tests/interactivemap_vector_style_test.py @@ -0,0 +1,138 @@ +# SPDX-FileCopyrightText: 2026 Rajveer Bishnoi +# SPDX-License-Identifier: GPL-2.0-or-later + +"""Test the vector style options shared by the two interactive backends""" + +import pytest + +from grass.jupyter import interactivemap +from grass.jupyter.interactivemap import vector_style + + +def test_translates_every_option(): + """Each option becomes the Leaflet name for it""" + options = { + "color": "black", + "fill_color": "#ffff00", + "width": 2, + "opacity": 0.8, + "fill_opacity": 0.4, + } + assert vector_style(options) == { + "color": "black", + "fillColor": "#ffff00", + "weight": 2, + "opacity": 0.8, + "fillOpacity": 0.4, + } + + +def test_takes_the_options_it_used(): + """The translated options are removed, the rest are left alone""" + options = {"color": "red", "name": "roads", "style_function": None} + vector_style(options) + assert options == {"name": "roads", "style_function": None} + + +def test_without_style_options(): + """Options which are not about style give an empty style""" + options = {"name": "roads"} + assert vector_style(options) == {} + assert options == {"name": "roads"} + + +@pytest.mark.parametrize( + ("option", "leaflet_name"), + [ + ("color", "color"), + ("fill_color", "fillColor"), + ("width", "weight"), + ("opacity", "opacity"), + ("fill_opacity", "fillOpacity"), + ], +) +def test_one_option_at_a_time(option, leaflet_name): + """One option on its own gives one Leaflet option""" + assert vector_style({option: 1}) == {leaflet_name: 1} + + +def test_named_option_wins_over_style(simple_dataset): + """A named option overrides the same key in a style given directly + + This is what the opacity shim did before the named options existed. + """ + ipyleaflet = pytest.importorskip("ipyleaflet") + + m = interactivemap.InteractiveMap(use_region=True, map_backend="ipyleaflet") + m.add_vector(simple_dataset.vector_name, opacity=0.4, style={"opacity": 0.9}) + + layer = next( + layer + for layer in reversed(m.map.layers) + if isinstance(layer, ipyleaflet.GeoJSON) + ) + assert layer.style["opacity"] == 0.4 + + +def test_style_keeps_keys_the_options_do_not_set(simple_dataset): + """Keys only present in a style given directly survive""" + ipyleaflet = pytest.importorskip("ipyleaflet") + + m = interactivemap.InteractiveMap(use_region=True, map_backend="ipyleaflet") + m.add_vector(simple_dataset.vector_name, opacity=0.4, style={"dashArray": "5"}) + + layer = next( + layer + for layer in reversed(m.map.layers) + if isinstance(layer, ipyleaflet.GeoJSON) + ) + assert layer.style == {"opacity": 0.4, "dashArray": "5"} + + +def test_style_none_is_the_backend_default(simple_dataset): + """style=None means the default, not a style to merge into""" + ipyleaflet = pytest.importorskip("ipyleaflet") + + m = interactivemap.InteractiveMap(use_region=True, map_backend="ipyleaflet") + m.add_vector(simple_dataset.vector_name, color="red", style=None) + + layer = next( + layer + for layer in reversed(m.map.layers) + if isinstance(layer, ipyleaflet.GeoJSON) + ) + assert layer.style == {"color": "red"} + + +def test_named_option_applies_over_a_style_function(simple_dataset): + """On folium the named options are applied on top of a style_function""" + pytest.importorskip("folium") + + m = interactivemap.InteractiveMap(use_region=True, map_backend="folium") + m.add_vector( + simple_dataset.vector_name, + color="red", + style_function=lambda _feature: {"dashArray": "6 4"}, + ) + + layer = next( + child + for child in m.map._children.values() + if child.__class__.__name__ == "GeoJson" + ) + assert layer.style_function(None) == {"dashArray": "6 4", "color": "red"} + + +def test_style_function_none_is_the_backend_default(simple_dataset): + """style_function=None means the default, so the named options still apply""" + pytest.importorskip("folium") + + m = interactivemap.InteractiveMap(use_region=True, map_backend="folium") + m.add_vector(simple_dataset.vector_name, color="red", style_function=None) + + layer = next( + child + for child in m.map._children.values() + if child.__class__.__name__ == "GeoJson" + ) + assert layer.style_function(None) == {"color": "red"}