Skip to content
1 change: 1 addition & 0 deletions python/grass/jupyter/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ MODULES = \
setup \
map \
interactivemap \
legend \
region \
map3d \
seriesmap \
Expand Down
85 changes: 85 additions & 0 deletions python/grass/jupyter/interactivemap.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import json
from pathlib import Path
from .reprojection_renderer import ReprojectionRenderer
from .legend import parse_colors, generate_legend_html

from .utils import (
get_region_bounds_latlon,
Expand Down Expand Up @@ -368,6 +369,90 @@ def add_raster(self, name, title=None, **kwargs):
self.raster_name.append(name)
Raster(name, title=title, renderer=self._renderer, **kwargs).add_to(self.map)

def add_legend(self, raster_name, title=None, position="bottomright", **kwargs):
"""Add a legend for a raster layer.

This method generates a legend based on the raster's color table
using r.colors.out. The legend is displayed as an HTML overlay
on the map.

:param str raster_name: Name of the raster map to create legend for
:param str title: Title to display in the legend (defaults to raster name)
:param str position: Position of the legend on the map
(e.g., "bottomright", "topleft")
:param kwargs: Additional keyword arguments:
- max_items: Maximum number of items to display for
continuous rasters (default: 12)
"""
if title is None:
title = raster_name

max_items = kwargs.get("max_items", 12)

# Parse color table
parsed = parse_colors(raster_name)

# Generate HTML
legend_html = generate_legend_html(parsed, title=title, max_items=max_items)

# Add to map based on backend
if hasattr(self, "_folium") and self._folium:
# For folium, use MacroElement with Jinja2 template
from branca.element import ( # pylint: disable=import-outside-toplevel
MacroElement,
)
from jinja2 import Template # pylint: disable=import-outside-toplevel

class LegendElement(MacroElement):
"""Custom legend element for folium maps."""

def __init__(self, legend_html, legend_position):
super().__init__()
self._name = "Legend"
self.legend_html = legend_html

# Convert Leaflet position to CSS position
if legend_position == "topright":
self.css_position = "top: 10px; right: 10px;"
elif legend_position == "topleft":
self.css_position = "top: 10px; left: 10px;"
elif legend_position == "bottomleft":
self.css_position = "bottom: 10px; left: 10px;"
else: # bottomright (default)
self.css_position = "bottom: 10px; right: 10px;"

self._template = Template(
"""
{% macro html(this, kwargs) %}
<div id='maplegend' class='maplegend'
style='position: absolute; z-index: 9999;
background-color: rgba(255, 255, 255, 0.9);
border: 2px solid grey; border-radius: 6px;
padding: 10px; font-size: 14px;
{{ this.css_position }}'>
{{ this.legend_html | safe }}
</div>
{% endmacro %}
"""
)

legend = LegendElement(legend_html, position)
self.map.get_root().add_child(legend)

elif hasattr(self, "_ipyleaflet") and self._ipyleaflet:
# For ipyleaflet, use WidgetControl
legend_widget = self._ipywidgets.HTML(value=legend_html)
legend_control = self._ipyleaflet.WidgetControl(
widget=legend_widget, position=position
)
self.map.add(legend_control)

# Force frontend refresh to ensure legend appears
try:
self.map.controls = tuple(self.map.controls)
except Exception:
pass

def add_layer_control(self, **kwargs):
"""Add layer control to display.

Expand Down
220 changes: 220 additions & 0 deletions python/grass/jupyter/legend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
#
# AUTHOR(S): Saurabh Singh
#
# PURPOSE: Legend support for interactive maps in Jupyter Notebooks
#
# COPYRIGHT: (C) 2025 by the GRASS Development Team
#
# This program is free software under the GNU General Public
# License (>=v2). Read the file COPYING that comes with GRASS
# for details.

"""Legend generation for raster layers in interactive maps"""

import grass.script as gs


def parse_colors(mapname):
"""Parse color table from a raster map using r.colors.out.

Detects raster datatype using r.info to determine whether
the legend should be categorical (CELL) or continuous
(FCELL/DCELL), similar to how d.legend behaves in GRASS.

:param str mapname: Name of the raster map
:return: Dictionary containing color information with keys:
- type: "categorical" or "continuous"
- items: list of dicts with value, label, and rgb
- nv: RGB tuple for null values (or None)
- default: RGB tuple for default color (or None)
:rtype: dict
"""
# Detect raster datatype using r.info
info = gs.raster_info(mapname)
datatype = info.get("datatype", "CELL")
is_categorical = datatype == "CELL"

raw = gs.read_command("r.colors.out", map=mapname)
lines = raw.strip().splitlines()

items = []
nv = None
default = None

for line in lines:
parts = line.split()
if parts[0] == "nv":
nv = tuple(map(int, parts[1].split(":")))
elif parts[0] == "default":
default = tuple(map(int, parts[1].split(":")))
else:
value = float(parts[0])
rgb = tuple(map(int, parts[1].split(":")))

label = f"Class {int(value)}" if is_categorical else f"{value:g}"

items.append({"value": value, "label": label, "rgb": rgb})

return {
"type": "categorical" if is_categorical else "continuous",
"items": items,
"nv": nv,
"default": default,
}


def _generate_categorical_html(items, title):
"""Generate categorical legend HTML with color boxes.

:param list items: List of color items
:param str title: Legend title
:return: HTML string
:rtype: str
"""
html = [
(
'<div class="maplegend leaflet-control" style="'
"pointer-events: auto;"
"background: #fff;"
"padding: 8px 10px;"
"border: 1px solid #ccc;"
"border-radius: 6px;"
"font-size: 0.85em;"
"font-family: sans-serif;"
"max-height: 200px;"
"overflow-y: auto;"
"min-width: 120px;"
"box-shadow: 0 1px 4px rgba(0,0,0,0.3);"
'">'
)
]

html.append(f"<strong>{title}</strong><br>")

for item in items:
r, g, b = item["rgb"]
html.append(
f'<div style="display: flex; align-items: center; gap: 6px; '
f'margin: 2px 0; white-space: nowrap;">'
f'<span style="width: 14px; height: 14px; '
f"background: rgb({r},{g},{b}); "
f"display: inline-block; "
f'border: 1px solid #000;"></span>'
f"<span>{item['label']}</span>"
f"</div>"
)

html.append("</div>")
return "\n".join(html)


def _generate_continuous_html(items, title):
"""Generate continuous gradient legend HTML with a color bar.

Creates a vertical gradient bar similar to d.legend in GRASS,
with min and max labels and intermediate tick marks.

:param list items: List of color items (breakpoints)
:param str title: Legend title
:return: HTML string
:rtype: str
"""
if not items:
return ""

# Build CSS gradient stops
min_val = items[0]["value"]
max_val = items[-1]["value"]
val_range = max_val - min_val if max_val != min_val else 1

gradient_stops = []
# Reverse items so stops go from top (0%) to bottom (100%)
for item in reversed(items):
r, g, b = item["rgb"]
# Calculate position as percentage (0% is top/max, 100% is bottom/min)
pct = 100 - ((item["value"] - min_val) / val_range * 100)
gradient_stops.append(f"rgb({r},{g},{b}) {pct:.1f}%")

gradient_css = ", ".join(gradient_stops)

# Select tick labels (show ~5 evenly spaced labels)
num_ticks = min(5, len(items))
if num_ticks > 1:
step = max(1, (len(items) - 1) // (num_ticks - 1))
tick_items = items[::step]
# Always include the last item
if tick_items[-1] != items[-1]:
tick_items.append(items[-1])
else:
tick_items = items

# Build tick marks HTML
ticks_html = []
for item in tick_items:
pct = 100 - ((item["value"] - min_val) / val_range * 100)
ticks_html.append(
f'<div style="position: absolute; right: 0; '
f"top: {pct:.1f}%; transform: translateY(-50%); "
f'font-size: 0.8em; white-space: nowrap;">'
f"&#8212; {item['label']}"
f"</div>"
)

html = [
(
'<div class="maplegend leaflet-control" style="'
"pointer-events: auto;"
"background: #fff;"
"padding: 8px 10px;"
"border: 1px solid #ccc;"
"border-radius: 6px;"
"font-size: 0.85em;"
"font-family: sans-serif;"
"min-width: 60px;"
"box-shadow: 0 1px 4px rgba(0,0,0,0.3);"
'">'
),
f"<strong>{title}</strong>",
'<div style="display: flex; gap: 4px; margin-top: 6px;">',
# Gradient bar
(
'<div style="width: 20px; height: 150px; '
f"background: linear-gradient(to bottom, {gradient_css}); "
'border: 1px solid #000;"></div>'
),
# Tick labels
'<div style="position: relative; width: 60px; height: 150px;">',
"\n".join(ticks_html),
"</div>",
"</div>",
"</div>",
]

return "\n".join(html)


def generate_legend_html(parsed, title="Legend", max_items=12):
"""Generate HTML for a raster legend.

For categorical (CELL) rasters, generates a legend with
color boxes. For continuous (FCELL/DCELL) rasters, generates
a gradient color bar similar to d.legend in GRASS.

:param dict parsed: Parsed color information from parse_colors()
:param str title: Title to display in the legend
:param int max_items: Maximum number of items for categorical legends
:return: HTML string for the legend
:rtype: str
"""
items = parsed["items"]
legend_type = parsed["type"]

if legend_type == "continuous":
return _generate_continuous_html(items, title)

# Categorical: limit items if needed
if len(items) > max_items:
step = max(1, len(items) // max_items)
items = items[::step]

return _generate_categorical_html(items, title)
Loading