"""The customized ``Map`` object."""
# known bug of rasterio
import os
from pysepal.mapping.bounds import (
compute_center,
compute_zoom_for_bounds,
viewport_pixels_from_map,
)
from pysepal.mapping.fullscreen_control import FullScreenControl
from pysepal.mapping.visualization import (
get_viz_params,
get_viz_params_async,
process_vis_params,
)
from pysepal.scripts.gee_interface import GEEInterface
from pysepal.sepalwidgets.vue_app import ThemeToggle
from pysepal.solara.theme import ThemeState
if "GDAL_DATA" in list(os.environ.keys()):
del os.environ["GDAL_DATA"]
if "PROJ_LIB" in list(os.environ.keys()):
del os.environ["PROJ_LIB"]
import json
import math
import random
import string
from pathlib import Path
from typing import List, Optional, Sequence, Union, cast
import ee
import ipyleaflet as ipl
import ipyvuetify as v
import ipywidgets as widgets
import matplotlib.pyplot as plt
import numpy as np
import rioxarray
from deprecated.sphinx import deprecated
from eeclient.client import EESession
from ipyleaflet import TileLayer # noqa: F401 - leave it here, it is used in the eval
from localtileserver import TileClient, get_leaflet_tile_layer
from matplotlib import colorbar
from matplotlib import colors as mpc
from rasterio.crs import CRS
from traitlets import Int as _TInt
from typing_extensions import Self
from pysepal import color as scolors
from pysepal import sepalwidgets as sw
from pysepal.frontend import styles as ss
from pysepal.mapping.basemaps import basemap_tiles
from pysepal.mapping.draw_control import GeomanDrawControl
from pysepal.mapping.inspector_control import InspectorControl
from pysepal.mapping.layer import EELayer
from pysepal.mapping.layer_state_control import LayerStateControl
from pysepal.mapping.layers_control import LayersControl
from pysepal.mapping.legend_control import LegendControl
from pysepal.mapping.zoom_control import ZoomControl
from pysepal.message import ms
from pysepal.scripts import decorator as sd
from pysepal.scripts import utils as su
__all__ = ["SepalMap"]
import logging
log = logging.getLogger("sepalui.mapping")
DEFAULT_MAP_WIDTH_PX = 1024
[docs]
class SepalMap(ipl.Map):
# ##########################################################################
# ### Map parameters ###
# ##########################################################################
gee: bool = True
"Either the map will use ee binding or not"
v_inspector: Optional[InspectorControl] = None
"The value inspector of the map"
dc: Optional[GeomanDrawControl] = None
"The drawing control of the map"
_id: str = ""
"A unique 6 letters str to identify the map in the DOM"
state: Optional[sw.StateBar] = None
"The statebar to inform the user about tile loading"
viewport_inset_left = _TInt(0)
"Left overlay width in px. Set by the parent shell (e.g. MapApp) so fit_bounds targets the visible region."
viewport_inset_right = _TInt(0)
"Right overlay width in px. Set by the parent shell (e.g. MapApp) so fit_bounds targets the visible region."
viewport_inset_bottom = _TInt(0)
"Bottom overlay height in px. Set by the parent shell (e.g. MapApp narrow-mode bottom panel) so fit_bounds targets the visible region."
canvas_width_px = _TInt(0)
"Canvas width in px, pushed from the frontend."
canvas_height_px = _TInt(0)
"Canvas height in px, pushed from the frontend."
[docs]
def __init__(
self,
basemaps: List[str] = [],
dc: bool = False,
vinspector: bool = False,
gee: bool = True,
statebar: bool = False,
theme_toggle: ThemeToggle = None,
theme_state: Optional[ThemeState] = None,
gee_session: Optional[EESession] = None,
gee_interface: Optional[GEEInterface] = None,
fullscreen: bool = False,
map_id: str = "",
**kwargs,
) -> None:
"""Custom Map object design to build application.
The SepalMap class inherits from ipyleaflet.Map. It can thus be initialized with all its parameter.
The map will fall back to CartoDB.DarkMatter map that well fits with the rest of the sepal_ui layout.
Numerous methods have been added in the class to help you deal with your workflow implementation.
It can natively display raster from .tif files and files and ee objects using methods
that have the same signature as the GEE JavaScripts console.
Args:
basemaps: the basemaps used as background in the map. If multiple selection, they will be displayed as layers.
dc: whether or not the drawing control should be displayed. default to false
vinspector: Add value inspector to map, useful to inspect pixel values. default to false
gee: whether or not to use the ee binding. If False none of the earthengine display functionalities can be used. default to True
statebar: whether or not to display the Statebar in the map
theme_toggle: deprecated ThemeToggle source. Use theme_state instead.
theme_state: shared theme state for Solara apps
gee_session (optional): a custom EESession object to do gee requests. default to None (deprecated in favor of gee_interface)
gee_interface: a shared GEEInterface instance. If provided, takes precedence over gee_session
fullscreen: whether or not to display the map in full screen. default to False
kwargs (optional): any parameter from a ipyleaflet.Map. if set, 'ee_initialize' will be overwritten.
Raises:
ValueError: if both gee_session and gee_interface are provided
.. versionadded:: 3.0.0
Added gee_interface parameter for sharing GEEInterface instances across components.
"""
# Validate input parameters
if gee_session and gee_interface:
raise ValueError(
"Cannot provide both gee_session and gee_interface. "
"Use gee_interface for shared instances or gee_session for component-specific sessions."
)
log.debug(
f"Map initialization with gee: {gee} and session: {gee_session} and interface: {gee_interface} ID: {id(gee_interface)}"
)
# set the default parameters
kwargs.setdefault("center", [0, 0])
kwargs.setdefault("zoom", 2)
kwargs.setdefault("max_zoom", 24)
kwargs["basemap"] = {}
kwargs["zoom_control"] = False
kwargs["attribution_control"] = False
kwargs["scroll_wheel_zoom"] = True
kwargs.setdefault("world_copy_jump", True)
if fullscreen:
self.add_class("full-screen-map")
super().__init__(**kwargs)
self._theme_source = None
# init ee
self.gee = gee
if gee:
if gee_interface:
self.gee_interface = gee_interface
else:
self.gee_interface = GEEInterface(session=gee_session)
su.init_ee()
# add the basemaps
self.clear()
theme_source = self._resolve_theme_source(theme_state, theme_toggle)
default_basemap = (
"CartoDB.DarkMatter" if self._theme_is_dark(theme_source) else "CartoDB.Positron"
)
basemaps = basemaps or [default_basemap]
[self.add_basemap(basemap) for basemap in set(basemaps)]
self._apply_theme_class(default_basemap == "CartoDB.DarkMatter")
# set the visibility of all the basemaps to False but the first one
[setattr(lyr, "visible", False) for lyr in self.layers]
self.layers[0].visible = True
# add the base controls
self.add(ZoomControl(self))
self.add(LayersControl(self, group=-1))
self.add(ipl.AttributionControl(position="bottomleft", prefix="SEPAL"))
self.add(ipl.ScaleControl(position="bottomleft", imperial=False))
if kwargs.get("fullscreen_control", False):
self.add(FullScreenControl(self))
# specific drawing control
self.dc = GeomanDrawControl(self)
not dc or self.add(self.dc)
# specific v_inspector
self.v_inspector = InspectorControl(self)
not vinspector or self.add(self.v_inspector)
# specific statebar
self.state = LayerStateControl(self)
not statebar or self.add(self.state)
# create a proxy ID to the element
# this id should be unique and will be used by mutators to identify this map
self._id = map_id or "".join(random.choice(string.ascii_lowercase) for i in range(6))
self.add_class(self._id)
self._bind_theme_source(theme_source)
def _on_theme_change(self, change) -> None:
"""Change the basemap layer."""
# This is the way to make it work in solara do not ask me why
light = eval(str(basemap_tiles["CartoDB.Positron"]))
dark = eval(str(basemap_tiles["CartoDB.DarkMatter"]))
layer_names = [layer.name for layer in self.layers]
self._apply_theme_class(change["new"])
if change["new"]:
if light.name in layer_names:
idx = layer_names.index(light.name)
layer = self.layers[idx]
self.remove_layer(layer, base=True, none_ok=True)
self.layers = self.layers[:idx] + (dark,) + self.layers[idx:]
else:
if dark.name in layer_names:
idx = layer_names.index(dark.name)
layer = self.layers[idx]
self.remove_layer(layer, base=True, none_ok=True)
self.layers = self.layers[:idx] + (light,) + self.layers[idx:]
def _apply_theme_class(self, is_dark: bool) -> None:
"""Keep a theme marker class on the map root for theme-aware CSS hooks."""
dark_class = "pysepal-map-theme-dark"
light_class = "pysepal-map-theme-light"
self.remove_class(light_class if is_dark else dark_class)
self.add_class(dark_class if is_dark else light_class)
[docs]
def bind_theme_state(self, theme_state: Optional[ThemeState]) -> None:
"""Bind the map to a shared theme state."""
self._bind_theme_source(theme_state if theme_state is not None else v.theme)
def _bind_theme_source(self, source) -> None:
"""Bind the map to the resolved source used to drive dark/light changes."""
if source is self._theme_source:
return
previous = self._theme_source
if previous is not None:
try:
previous.unobserve(self._on_theme_change, "dark")
except (AttributeError, KeyError, ValueError):
pass
source.observe(self._on_theme_change, "dark")
self._theme_source = source
is_dark = self._theme_is_dark(source)
if previous is None:
# Initial bind: basemaps already reflect the theme; only sync the CSS marker.
self._apply_theme_class(is_dark)
else:
self._on_theme_change({"new": is_dark})
@staticmethod
def _theme_is_dark(source) -> bool:
"""Normalize theme sources that expose a `dark` traitlet."""
return getattr(source, "dark", None) is True
@staticmethod
def _resolve_theme_source(
theme_state: Optional[ThemeState], theme_toggle: Optional[ThemeToggle]
):
"""Resolve the theme source used to initialize and drive the map theme."""
if theme_state is not None:
return theme_state
if theme_toggle is not None:
return SepalMap._resolve_deprecated_theme_toggle_source(theme_toggle)
return v.theme
@staticmethod
@deprecated(
version="3.4.0",
reason="SepalMap(theme_toggle=...) is deprecated; pass theme_state=... instead.",
category=DeprecationWarning,
)
def _resolve_deprecated_theme_toggle_source(theme_toggle: ThemeToggle):
"""Resolve the legacy theme_toggle constructor path."""
if hasattr(theme_toggle, "get_theme_state"):
bound_theme_state = theme_toggle.get_theme_state()
if bound_theme_state is not None:
return bound_theme_state
return theme_toggle
[docs]
def show_dc(self) -> Self:
"""Show the drawing control on the map.
Returns:
Self for method chaining
"""
self.dc.show()
return self
[docs]
def hide_dc(self) -> Self:
"""Hide the drawing control of the map.
Returns:
Self for method chaining
"""
self.dc.hide()
return self
[docs]
def set_center(self, lon: float, lat: float, zoom: int = -1) -> None:
"""Centers the map view at a given coordinates with the given zoom level.
Args:
lon: The longitude of the center, in degrees.
lat: The latitude of the center, in degrees.
zoom: The zoom level, from 1 to 24. Defaults to None.
"""
self.center = [lat, lon]
self.zoom = self.zoom if zoom == -1 else zoom
return
[docs]
@sd.need_ee
def zoom_ee_object(self, item: ee.ComputedObject, zoom_out: int = 1) -> Self:
"""Get the proper zoom to the given ee geometry.
Args:
item: the geometry to zoom on
zoom_out: Zoom out the bounding zoom
"""
# type check the given object
ee_geometry = item if isinstance(item, ee.Geometry) else item.geometry()
# extract bounds from ee_object
coords = self.gee_interface.get_info(ee_geometry.bounds().coordinates().get(0))
# zoom on these bounds
return self.zoom_bounds((*coords[0], *coords[2]), zoom_out)
[docs]
def zoom_raster(self, layer: ipl.LocalTileLayer, zoom_out: int = 1) -> Self:
"""Adapt the zoom to the given LocalLayer.
The localLayer need to come from the add_raster method to embed the image name.
Args:
layer: the localTile layer to zoom on. it needs to embed the "raster" member
zoom_out: Zoom out the bounding zoom
"""
da = rioxarray.open_rasterio(layer.raster, masked=True)
# unproject if necessary
epsg_4326 = "EPSG:4326"
if da.rio.crs != CRS.from_string(epsg_4326):
da = da.rio.reproject(epsg_4326)
return self.zoom_bounds(da.rio.bounds(), zoom_out)
[docs]
def zoom_bounds(self, bounds: Sequence[float], zoom_out: int = 1) -> Self:
"""Adapt the zoom to the given bounds. and center the image.
Args:
bounds: coordinates corners as minx, miny, maxx, maxy in EPSG:4326
zoom_out: Zoom out the bounding zoom
"""
# center the map
minx, miny, maxx, maxy = bounds
self.fit_bounds([[miny, minx], [maxy, maxx]])
# adapt the zoom level
zoom_out = (self.zoom - 1) if zoom_out > self.zoom else zoom_out
self.zoom -= zoom_out
return self
[docs]
def add_raster(
self,
image: Union[str, Path],
bands: Optional[Union[list, int]] = None,
layer_name: str = "Layer_" + su.random_string(),
colormap: Union[str, mpc.Colormap] = "inferno",
opacity: float = 1.0,
fit_bounds: bool = True,
key: str = "",
) -> ipl.TileLayer:
"""Adds a local raster dataset to the map.
If used on a cloud platform (or distant jupyter), this method won't know where the entry point of the client is set and will thus fail to display the image. Please follow instructions from https://localtileserver.banesullivan.com/installation/remote-jupyter.html and set up the ``LOCALTILESERVER_CLIENT_PREFIX`` environment variable.
Args:
image: The image file path.
bands: The image bands to use. It can be either a number (e.g., 1) or a list (e.g., [3, 2, 1]). Defaults to None.
layer_name: The layer name to use for the raster. Defaults to None. If a layer is already using this name 3 random letter will be added
colormap: The name of the colormap to use for the raster, such as 'gray' and 'terrain'. More can be found at https://matplotlib.org/3.1.0/tutorials/colors/colormaps.html. Defaults to inferno.
opacity: the opacity of the layer, default 1.0.
key: the unequivocal key of the layer. by default use a normalized str of the layer name
fit_bounds: Whether or not we should fit the map to the image bounds. Default to True.
Returns:
the local tile layer embedding the raster member (to be used with other tools of sepal-ui)
"""
# force cast to Path and then start the client
image = Path(image)
if not image.is_file():
raise Exception(ms.mapping.no_image)
client = TileClient(image)
# check inputs
if layer_name in [layer.name for layer in self.layers]:
layer_name = layer_name + su.random_string()
# set the colors as independent colors
if isinstance(colormap, str):
cmap = plt.get_cmap(name=colormap)
color_list = [mpc.rgb2hex(cmap(i)) for i in range(cmap.N)]
da = rioxarray.open_rasterio(image, masked=True)
# print
print(da)
da = da.chunk({"x": 1000, "y": 1000})
multi_band = False
if len(da.band) > 1 and not isinstance(bands, int):
multi_band = True
bands = bands if bands else [3, 2, 1]
elif len(da.band) == 1:
bands = 1
if multi_band:
cast(list, bands)
style = {
"bands": [
{"band": bands[0], "palette": "#f00"},
{"band": bands[1], "palette": "#0f0"},
{"band": bands[2], "palette": "#00f"},
]
}
else:
style = {
"bands": [
{"band": bands, "palette": color_list},
]
}
# create the layer
layer = get_leaflet_tile_layer(
client,
style=style,
name=layer_name,
opacity=opacity,
max_zoom=20,
max_native_zoom=20,
)
self.add_layer(layer, key=key)
# add the da to the layer as an extra member for the v_inspector
layer.raster = str(image)
# zoom on the layer if requested
if fit_bounds is True:
self.center = client.center()
self.zoom = client.default_zoom
return layer
[docs]
def add_colorbar(
self,
colors: list,
cmap: str = "viridis",
vmin: float = 0.0,
vmax: float = 1.0,
index: list = [],
categorical: bool = False,
step: int = 0,
transparent_bg: bool = False,
position: str = "bottomright",
layer_name: str = "",
**kwargs,
) -> None:
"""Add a colorbar to the map.
Args:
colors: The set of colors to be used for interpolation. Colors can be provided in the form: * tuples of RGBA ints between 0 and 255 (e.g: (255, 255, 0) or (255, 255, 0, 255)) * tuples of RGBA floats between 0. and 1. (e.g: (1.,1.,0.) or (1., 1., 0., 1.)) * HTML-like string (e.g: “#ffff00) * a color name or shortcut (e.g: “y” or “yellow”)
cmap: a matplotlib colormap default to viridis
vmin: The minimal value for the colormap. Values lower than vmin will be bound directly to colors[0].. Defaults to 0.
vmax: The maximal value for the colormap. Values higher than vmax will be bound directly to colors[-1]. Defaults to 1.0.
index: The values corresponding to each color. It has to be sorted, and have the same length as colors. If None, a regular grid between vmin and vmax is created. Defaults to None.
categorical (bool, optional): Whether or not to create a categorical colormap. Defaults to False.
step: The step to split the LinearColormap into a StepColormap. Defaults to None.
position: The position for the colormap widget. Defaults to "bottomright".
layer_name: Layer name of the colorbar to be associated with. Defaults to None.
kwargs: any other argument of the colorbar object from matplotlib
"""
width, height = 6.0, 0.4
alpha = 1
if colors is not None:
# transform colors in hex colors
hexcodes = [su.to_colors(c) for c in colors]
if categorical:
plot_color = mpc.ListedColormap(hexcodes)
vals = np.linspace(vmin, vmax, plot_color.N + 1)
norm = mpc.BoundaryNorm(vals, plot_color.N)
else:
plot_color = mpc.LinearSegmentedColormap.from_list("custom", hexcodes, N=256)
norm = mpc.Normalize(vmin=vmin, vmax=vmax)
elif cmap is not None:
plot_color = plt.get_cmap(cmap)
norm = mpc.Normalize(vmin=vmin, vmax=vmax)
else:
msg = '"cmap" keyword or "colors" key must be provided.'
raise ValueError(msg)
style = (
"dark_background" if self._theme_is_dark(self._theme_source or v.theme) else "classic"
)
with plt.style.context(style):
fig, ax = plt.subplots(figsize=(width, height))
cb = colorbar.ColorbarBase(
ax,
norm=norm,
alpha=alpha,
cmap=plot_color,
orientation="horizontal",
**kwargs,
)
# cosmetic improvement
cb.outline.set_visible(False) # remove border of the color bar
ax.tick_params(size=0) # remove ticks
fig.patch.set_alpha(0.0) # remove bg of the fig
ax.patch.set_alpha(0.0) # remove bg of the ax
not layer_name or cb.set_label(layer_name)
output = widgets.Output()
colormap_ctrl = ipl.WidgetControl(
widget=output,
position=position,
transparent_bg=True,
)
with output:
output.clear_output()
plt.show()
self.add(colormap_ctrl)
return
[docs]
def add_ee_layer(
self,
ee_object: ee.ComputedObject,
vis_params: dict = {},
name: str = "",
shown: bool = True,
opacity: float = 1.0,
viz_name: str = "",
key: str = "",
use_map_vis: bool = True,
autocenter: bool = False,
) -> None:
"""Customized add_layer method designed for EE objects.
Copy the addLayer method from geemap to read and guess the vizaulization
parameters the same way as in SEPAL recipes.
If the vizparams are empty and visualization metadata exist, SepalMap will use
them automatically.
Args:
ee_object: the ee OBject to draw on the map
vis_params: the visualization parameters set as in GEE
name: the name of the layer
shown: either to show the layer or not, default to true (it is bugged in ipyleaflet)
opacity: the opcity of the layer from 0 to 1, default to 1.
viz_name: the name of the vizaulization you want to use. default to the first one if existing
key: the unequivocal key of the layer. by default use a normalized str of the layer name
use_map_vis: whether or not to use the map visualization parameters. default to True
autocenter: whether or not to center the map on the layer. default to False
"""
# get the own visualization parameters
map_own_visualization = get_viz_params(ee_object, gee_interface=self.gee_interface)
image, obj, vis_params = process_vis_params(
ee_object,
vis_params=vis_params,
viz=map_own_visualization,
use_map_vis=use_map_vis,
viz_name=viz_name,
)
# create the layer based on these new values
if not name:
layer_count = len(self.layers)
name = "Layer " + str(layer_count + 1)
# create the colored image
map_id_dict = self.gee_interface.get_map_id(image, vis_params)
tile_layer = EELayer(
ee_object=obj,
url=map_id_dict["tile_fetcher"].url_format,
attribution="Google Earth Engine",
name=name,
opacity=opacity,
visible=shown,
max_zoom=24,
)
if autocenter:
try:
ee_geometry = (
ee_object if isinstance(ee_object, ee.Geometry) else ee_object.geometry()
)
bounds = self.gee_interface.get_info(ee_geometry.bounds().coordinates().get(0))
self.zoom_bounds((*bounds[0], *bounds[2]))
except Exception:
log.debug("autocenter skipped: unable to compute bounds (unbounded image?)")
self.add_layer(tile_layer, key=key)
return
[docs]
async def add_ee_layer_async(
self,
ee_object: ee.ComputedObject,
vis_params: dict = {},
name: str = "",
shown: bool = True,
opacity: float = 1.0,
viz_name: str = "",
key: str = "",
use_map_vis: bool = True,
autocenter: bool = False,
) -> None:
"""Customized add_layer method designed for EE objects.
Copy the addLayer method from geemap to read and guess the vizaulization
parameters the same way as in SEPAL recipes.
If the vizparams are empty and visualization metadata exist, SepalMap will use
them automatically.
Args:
ee_object: the ee OBject to draw on the map
vis_params: the visualization parameters set as in GEE
name: the name of the layer
shown: either to show the layer or not, default to true (it is bugged in ipyleaflet)
opacity: the opcity of the layer from 0 to 1, default to 1.
viz_name: the name of the vizaulization you want to use. default to the first one if existing
key: the unequivocal key of the layer. by default use a normalized str of the layer name
use_map_vis: whether or not to use the map visualization parameters. default to True
autocenter: whether or not to center the map on the layer. default to False
"""
# get the own visualization parameters
map_own_visualization = await get_viz_params_async(
ee_object,
gee_interface=self.gee_interface,
)
image, obj, vis_params = process_vis_params(
ee_object,
vis_params=vis_params,
viz=map_own_visualization,
use_map_vis=use_map_vis,
viz_name=viz_name,
)
# create the layer based on these new values
if not name:
layer_count = len(self.layers)
name = "Layer " + str(layer_count + 1)
# create the colored image
map_id_dict = await self.gee_interface.get_map_id_async(image, vis_params)
tile_layer = EELayer(
ee_object=obj,
url=map_id_dict["tile_fetcher"].url_format,
attribution="Google Earth Engine",
name=name,
opacity=opacity,
visible=shown,
max_zoom=24,
)
if autocenter:
try:
ee_geometry = (
ee_object if isinstance(ee_object, ee.Geometry) else ee_object.geometry()
)
bounds = await self.gee_interface.get_info_async(
ee_geometry.bounds().coordinates().get(0)
)
self.zoom_bounds((*bounds[0], *bounds[2]))
except Exception:
log.debug("autocenter skipped: unable to compute bounds (unbounded image?)")
self.add_layer(tile_layer, key=key)
return
[docs]
@staticmethod
def get_basemap_list() -> List[str]:
"""Get the complete list of available basemaps.
This function is intending for development use
It give the list of all the available basemaps for SepalMap object.
Returns:
The list of the basemap names
"""
return [k for k in basemap_tiles.keys()]
[docs]
def remove_layer(
self, key: Union[ipl.Layer, int, str], base: bool = False, none_ok: bool = False
) -> None:
"""Remove a layer based on a key.
The key can be, a Layer object, the name of a layer or the index in the layer list.
Args:
key: the key to find the layer to delete
base: either the basemaps should be included in the search or not. default t false
none_ok: if True the function will not raise error if no layer is found. Default to False
"""
layer = self.find_layer(key, base, none_ok)
# the error is caught in find_layer
if layer is not None:
super().remove(layer)
return
[docs]
def remove_all(self, base: bool = False, keep_names: Optional[list[str]] = None) -> None:
"""Remove all the layers from the maps.
If base is set to True, the basemaps are removed as well.
Args:
base: whether or not the basemaps should be removed, default to False
keep_names: if set, will keep the layers with these names
"""
# filter out the basemaps if base == False
layers = self.layers if base else [lyr for lyr in self.layers if not lyr.base]
# remove them using the layer objects as keys
[
self.remove_layer(layer, base)
for layer in layers
if not keep_names or layer.name not in keep_names
]
return
[docs]
def add_layer(self, layer: ipl.Layer, hover: bool = False, key: str = "") -> None:
"""Add layer and use a default style for the GeoJSON inputs.
Remove existing layer if already on the map.
Args:
layer: any layer type from ipyleaflet
hover: whether to use the default hover style or not.
key: the unequivocal key of the layer. by default use a normalized str of the layer name
"""
# set up a unique key
layer.key = key if key else su.normalize_str(layer.name)
# remove existing layer before addition
existing_layer = self.find_layer(layer.key, none_ok=True)
not existing_layer or self.remove_layer(existing_layer)
# apply default coloring for geoJson
if isinstance(layer, ipl.GeoJSON):
# define the default values
default_style = json.loads((ss.JSON_DIR / "layer.json").read_text())["layer"]
default_style.update(color=scolors.primary)
default_hover_style = json.loads((ss.JSON_DIR / "layer_hover.json").read_text())
default_hover_style.update(color=scolors.primary)
# apply the style depending on the parameters
layer.style = layer.style or default_style
hover_style = default_hover_style if hover else layer.hover_style
layer.hover_style = layer.hover_style or hover_style
super().add(layer)
return
[docs]
def add_basemap(self, basemap: str = "HYBRID") -> None:
"""Adds a basemap to the map.
Args:
basemap: Can be one of string from basemaps. Defaults to 'HYBRID'.
"""
if basemap not in basemap_tiles.keys():
keys = "\n".join(basemap_tiles.keys())
msg = f"Basemap can only be one of the following:\n{keys}"
raise ValueError(msg)
self.add_layer(eval(str(basemap_tiles[basemap])))
return
[docs]
def get_scale(self) -> float:
"""Returns the approximate pixel scale of the current map view, in meters.
Reference: https://blogs.bing.com/maps/2006/02/25/map-control-zoom-levels-gt-resolution.
Returns:
Map resolution in meters.
"""
return 156543.04 * math.cos(0) / math.pow(2, self.zoom)
[docs]
def find_layer(
self, key: Union[ipl.Layer, str, int], base: bool = False, none_ok: bool = False
) -> ipl.TileLayer:
"""Search a layer by name or index.
Args:
key: the layer name, the layer key, the index or directly the layer
base: either the basemaps should be included in the search or not. default to false
none_ok: if True the function will not raise error if no layer is found. Default to False
Returns:
The first layer using the same name or index else None
"""
# filter the layers
layers = self.layers if base else [lyr for lyr in self.layers if not lyr.base]
if isinstance(key, str):
layer = next((lyr for lyr in layers if lyr.key == key), None)
layer = layer or next((lyr for lyr in layers if lyr.name == key), None)
elif isinstance(key, int):
size = len(layers)
layer = layers[key] if -size <= key < size else None
elif isinstance(key, ipl.Layer):
layer = next((lyr for lyr in layers if lyr == key), None)
else:
raise ValueError(f"key must be a int or a str, {type(key)} given")
if layer is None and none_ok is False:
raise ValueError(f"no layer corresponding to {key} on the map")
return layer
[docs]
def fit_bounds(self, bounds):
"""Abstract method to fit the map to the given bounds."""
# I've done this because the native ipyleaflet fit bounds method uses
# awaitables that create conflicts with solara.
# Also I don't like the way it zooms by levels
# Canvas (width, height) in pixels. Prefer values pushed from the
# frontend (accurate from render 0); fall back to bounds-derived
# (accurate post-render); finally to defaults. The pushed path is
# what fixes the "first fit_bounds is too far" cold-start bug —
# before the first render self.bounds is the default world view.
derived_w, derived_h = viewport_pixels_from_map(self)
canvas_w = int(self.canvas_width_px) or derived_w or DEFAULT_MAP_WIDTH_PX
canvas_h = int(self.canvas_height_px) or derived_h or (canvas_w * 9 / 16)
# Subtract overlay panels (nav drawer, right panel) from the *width*
# and the narrow-mode bottom panel from the *height* so fit_bounds
# targets the truly visible region.
left = max(int(self.viewport_inset_left), 0)
right = max(int(self.viewport_inset_right), 0)
bottom = max(int(self.viewport_inset_bottom), 0)
visible_w = max(canvas_w - left - right, 1)
visible_h = max(canvas_h - bottom, 1)
log.debug(
f"canvas=({canvas_w:.0f},{canvas_h:.0f}) "
f"visible=({visible_w:.0f},{visible_h:.0f}) "
f"insets=({left},{right},{bottom})"
)
zoom = (
compute_zoom_for_bounds(
bounds,
map_width_px=visible_w,
map_height_px=visible_h,
min_zoom=getattr(self, "min_zoom", None),
max_zoom=getattr(self, "max_zoom", None),
)
+ 1
)
self.zoom = zoom
# Shift the map center so the target's geographic center lands at the
# *visible* center, not the canvas center.
# X (longitude) is linear in Web Mercator pixels: 360° / (256 * 2**zoom).
# Y (latitude) scale near lat_c is the X scale * cos(lat_c).
lat_c, lon_c = compute_center(bounds)
deg_per_px_x = 360.0 / (256 * (2**zoom))
deg_per_px_y = deg_per_px_x * math.cos(math.radians(lat_c))
px_offset_x = (left - right) / 2 # +ve when left panel dominates
# bottom panel pushes the visible region upward → canvas center is
# below visible center → map center must shift south (lower lat).
px_offset_y = bottom / 2
lon_shift = px_offset_x * deg_per_px_x
lat_shift = px_offset_y * deg_per_px_y
self.center = (lat_c - lat_shift, lon_c - lon_shift)
[docs]
def add_legend(
self,
title: str = ms.mapping.legend,
legend_dict: dict = {},
position: str = "bottomright",
vertical: bool = True,
) -> None:
"""Creates and adds a custom legend as widget control to the map.
Args:
title: Title of the legend. Defaults to 'Legend'.
legend_dict: dictionary with key as label name and value as color
position: the position (corners) of the legend on the map
vertical: vertical or horizoal position of the legend
"""
# Define as class member so it can be accessed from outside.
self.legend = LegendControl(legend_dict, title=title, vertical=vertical, position=position)
return self.add(self.legend)
# ##########################################################################
# ### overwrite geemap calls ###
# ##########################################################################
setCenter = set_center
centerObject = zoom_ee_object
addLayer = add_ee_layer
getScale = get_scale