Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 54 additions & 8 deletions python/grass/jupyter/interactivemap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand All @@ -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
)
Expand Down
138 changes: 138 additions & 0 deletions python/grass/jupyter/tests/interactivemap_vector_style_test.py
Original file line number Diff line number Diff line change
@@ -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"}
Loading