diff --git a/docs/map_widgets.md b/docs/map_widgets.md new file mode 100644 index 0000000000..33169afd06 --- /dev/null +++ b/docs/map_widgets.md @@ -0,0 +1,3 @@ +# map_widgets module + +::: geemap.map_widgets diff --git a/geemap/ee_tile_layers.py b/geemap/ee_tile_layers.py index 86a2c9b5ef..11f22939c8 100644 --- a/geemap/ee_tile_layers.py +++ b/geemap/ee_tile_layers.py @@ -60,6 +60,8 @@ def _ee_object_to_image(ee_object, vis_params): def _validate_palette(palette): + if isinstance(palette, tuple): + palette = list(palette) if isinstance(palette, box.Box): if "default" not in palette: raise ValueError("The provided palette Box object is invalid.") @@ -92,7 +94,9 @@ def __init__( shown (bool, optional): A flag indicating whether the layer should be on by default. Defaults to True. opacity (float, optional): The layer's opacity represented as a number between 0 and 1. Defaults to 1. """ - self.url_format = _get_tile_url_format(ee_object, _validate_vis_params(vis_params)) + self.url_format = _get_tile_url_format( + ee_object, _validate_vis_params(vis_params) + ) super().__init__( tiles=self.url_format, attr="Google Earth Engine", @@ -127,7 +131,9 @@ def __init__( shown (bool, optional): A flag indicating whether the layer should be on by default. Defaults to True. opacity (float, optional): The layer's opacity represented as a number between 0 and 1. Defaults to 1. """ - self.url_format = _get_tile_url_format(ee_object, _validate_vis_params(vis_params)) + self.url_format = _get_tile_url_format( + ee_object, _validate_vis_params(vis_params) + ) super().__init__( url=self.url_format, attribution="Google Earth Engine", diff --git a/geemap/geemap.py b/geemap/geemap.py index f330df851b..6da0115d3d 100644 --- a/geemap/geemap.py +++ b/geemap/geemap.py @@ -26,8 +26,9 @@ from .common import * from .conversion import * from .ee_tile_layers import * -from .timelapse import * +from . import map_widgets from .plot import * +from .timelapse import * from . import examples @@ -1014,7 +1015,7 @@ def add_colorbar( layer_name=None, font_size=9, axis_off=False, - max_width="270px", + max_width=None, **kwargs, ): """Add a matplotlib colorbar to the map @@ -1030,142 +1031,32 @@ def add_colorbar( layer_name (str, optional): The layer name associated with the colorbar. Defaults to None. font_size (int, optional): Font size for the colorbar. Defaults to 9. axis_off (bool, optional): Whether to turn off the axis. Defaults to False. - max_width (str, optional): Maximum width of the colorbar in pixels. Defaults to "300px". + max_width (str, optional): Maximum width of the colorbar in pixels. Defaults to None. Raises: TypeError: If the vis_params is not a dictionary. ValueError: If the orientation is not either horizontal or vertical. - ValueError: If the provided min value is not scalar type. - ValueError: If the provided max value is not scalar type. - ValueError: If the provided opacity value is not scalar type. - ValueError: If cmap or palette is not provided. + TypeError: If the provided min value is not scalar type. + TypeError: If the provided max value is not scalar type. + TypeError: If the provided opacity value is not scalar type. + TypeError: If cmap or palette is not provided. """ - import matplotlib as mpl - import matplotlib.pyplot as plt - import numpy as np - - if isinstance(vis_params, list): - vis_params = {"palette": vis_params} - elif isinstance(vis_params, tuple): - vis_params = {"palette": list(vis_params)} - elif vis_params is None: - vis_params = {} - - if "colors" in kwargs and isinstance(kwargs["colors"], list): - vis_params["palette"] = kwargs["colors"] - - if "colors" in kwargs and isinstance(kwargs["colors"], tuple): - vis_params["palette"] = list(kwargs["colors"]) - - if "vmin" in kwargs: - vis_params["min"] = kwargs["vmin"] - del kwargs["vmin"] - - if "vmax" in kwargs: - vis_params["max"] = kwargs["vmax"] - del kwargs["vmax"] - - if "caption" in kwargs: - label = kwargs["caption"] - del kwargs["caption"] - - if not isinstance(vis_params, dict): - raise TypeError("The vis_params must be a dictionary.") - - if orientation not in ["horizontal", "vertical"]: - raise ValueError("The orientation must be either horizontal or vertical.") - - if orientation == "horizontal": - width, height = 3.0, 0.3 - else: - width, height = 0.3, 3.0 - - if "width" in kwargs: - width = kwargs["width"] - kwargs.pop("width") - - if "height" in kwargs: - height = kwargs["height"] - kwargs.pop("height") - - vis_keys = list(vis_params.keys()) - - if "min" in vis_params: - vmin = vis_params["min"] - if type(vmin) not in (int, float): - raise ValueError("The provided min value must be scalar type.") - else: - vmin = 0 - if "max" in vis_params: - vmax = vis_params["max"] - if type(vmax) not in (int, float): - raise ValueError("The provided max value must be scalar type.") - else: - vmax = 1 - - if "opacity" in vis_params: - alpha = vis_params["opacity"] - if type(alpha) not in (int, float): - raise ValueError("The provided opacity value must be type scalar.") - elif "alpha" in kwargs: - alpha = kwargs["alpha"] - else: - alpha = 1 - - if cmap is not None: - cmap = mpl.pyplot.get_cmap(cmap) - norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax) - - if "palette" in vis_keys: - hexcodes = to_hex_colors(check_cmap(vis_params["palette"])) - if discrete: - cmap = mpl.colors.ListedColormap(hexcodes) - vals = np.linspace(vmin, vmax, cmap.N + 1) - norm = mpl.colors.BoundaryNorm(vals, cmap.N) - - else: - cmap = mpl.colors.LinearSegmentedColormap.from_list( - "custom", hexcodes, N=256 - ) - norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax) - - elif cmap is not None: - cmap = mpl.pyplot.get_cmap(cmap) - norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax) - - else: - raise ValueError( - 'cmap keyword or "palette" key in vis_params must be provided.' - ) - - fig, ax = plt.subplots(figsize=(width, height)) - cb = mpl.colorbar.ColorbarBase( - ax, norm=norm, alpha=alpha, cmap=cmap, orientation=orientation, **kwargs + colorbar = map_widgets.Colorbar( + vis_params, + cmap, + discrete, + label, + orientation, + transparent_bg, + font_size, + axis_off, + max_width, + **kwargs, ) - - if label is not None: - cb.set_label(label, fontsize=font_size) - elif "bands" in vis_keys: - cb.set_label(vis_params["bands"], fontsize=font_size) - - if axis_off: - ax.set_axis_off() - ax.tick_params(labelsize=font_size) - - # set the background color to transparent - if transparent_bg: - fig.patch.set_alpha(0.0) - - output = widgets.Output(layout=widgets.Layout(width=max_width)) colormap_ctrl = ipyleaflet.WidgetControl( - widget=output, - position=position, - transparent_bg=transparent_bg, + widget=colorbar, position=position, transparent_bg=transparent_bg ) - with output: - output.outputs = () - plt.show() self._colorbar = colormap_ctrl if layer_name in self.ee_layer_names: diff --git a/geemap/map_widgets.py b/geemap/map_widgets.py new file mode 100644 index 0000000000..1e2f8e70bb --- /dev/null +++ b/geemap/map_widgets.py @@ -0,0 +1,136 @@ +"""Various ipywidgets that can be added to a map.""" + +import ipywidgets + +from . import common + + +class Colorbar(ipywidgets.Output): + """A matplotlib colorbar widget that can be added to the map.""" + + def __init__( + self, + vis_params=None, + cmap="gray", + discrete=False, + label=None, + orientation="horizontal", + transparent_bg=False, + font_size=9, + axis_off=False, + max_width=None, + **kwargs, + ): + """Add a matplotlib colorbar to the map. + + Args: + vis_params (dict): Visualization parameters as a dictionary. See https://developers.google.com/earth-engine/guides/image_visualization for options. + cmap (str, optional): Matplotlib colormap. Defaults to "gray". See https://matplotlib.org/3.3.4/tutorials/colors/colormaps.html#sphx-glr-tutorials-colors-colormaps-py for options. + discrete (bool, optional): Whether to create a discrete colorbar. Defaults to False. + label (str, optional): Label for the colorbar. Defaults to None. + orientation (str, optional): Orientation of the colorbar, such as "vertical" and "horizontal". Defaults to "horizontal". + transparent_bg (bool, optional): Whether to use transparent background. Defaults to False. + font_size (int, optional): Font size for the colorbar. Defaults to 9. + axis_off (bool, optional): Whether to turn off the axis. Defaults to False. + max_width (str, optional): Maximum width of the colorbar in pixels. Defaults to None. + + Raises: + TypeError: If the vis_params is not a dictionary. + ValueError: If the orientation is not either horizontal or vertical. + ValueError: If the provided min value is not scalar type. + ValueError: If the provided max value is not scalar type. + ValueError: If the provided opacity value is not scalar type. + ValueError: If cmap or palette is not provided. + """ + + import matplotlib # pylint: disable=import-outside-toplevel + import numpy # pylint: disable=import-outside-toplevel + + if max_width is None: + if orientation == "horizontal": + max_width = "270px" + else: + max_width = "100px" + + if isinstance(vis_params, (list, tuple)): + vis_params = {"palette": list(vis_params)} + elif not vis_params: + vis_params = {} + + if not isinstance(vis_params, dict): + raise TypeError("The vis_params must be a dictionary.") + + if isinstance(kwargs.get("colors"), (list, tuple)): + vis_params["palette"] = list(kwargs["colors"]) + + width, height = self._get_dimensions(orientation, kwargs) + + vmin = vis_params.get("min", kwargs.pop("vmin", 0)) + if type(vmin) not in (int, float): + raise TypeError("The provided min value must be scalar type.") + + vmax = vis_params.get("max", kwargs.pop("mvax", 1)) + if type(vmax) not in (int, float): + raise TypeError("The provided max value must be scalar type.") + + alpha = vis_params.get("opacity", kwargs.pop("alpha", 1)) + if type(alpha) not in (int, float): + raise TypeError("The provided opacity or alpha value must be type scalar.") + + if "palette" in vis_params.keys(): + hexcodes = common.to_hex_colors(common.check_cmap(vis_params["palette"])) + if discrete: + cmap = matplotlib.colors.ListedColormap(hexcodes) + linspace = numpy.linspace(vmin, vmax, cmap.N + 1) + norm = matplotlib.colors.BoundaryNorm(linspace, cmap.N) + else: + cmap = matplotlib.colors.LinearSegmentedColormap.from_list( + "custom", hexcodes, N=256 + ) + norm = matplotlib.colors.Normalize(vmin=vmin, vmax=vmax) + elif cmap: + cmap = matplotlib.pyplot.get_cmap(cmap) + norm = matplotlib.colors.Normalize(vmin=vmin, vmax=vmax) + else: + raise ValueError( + 'cmap keyword or "palette" key in vis_params must be provided.' + ) + + fig, ax = matplotlib.pyplot.subplots(figsize=(width, height)) + cb = matplotlib.colorbar.ColorbarBase( + ax, + norm=norm, + alpha=alpha, + cmap=cmap, + orientation=orientation, + **kwargs, + ) + + label = label or vis_params.get("bands") or kwargs.pop("caption", None) + if label: + cb.set_label(label, fontsize=font_size) + + if axis_off: + ax.set_axis_off() + ax.tick_params(labelsize=font_size) + + # Set the background color to transparent. + if transparent_bg: + fig.patch.set_alpha(0.0) + + super().__init__(layout=ipywidgets.Layout(width=max_width)) + with self: + self.outputs = () + matplotlib.pyplot.show() + + def _get_dimensions(self, orientation, kwargs): + default_dims = {"horizontal": (3.0, 0.3), "vertical": (0.3, 3.0)} + if orientation in default_dims: + default = default_dims[orientation] + return ( + kwargs.get("width", default[0]), + kwargs.get("height", default[1]), + ) + raise ValueError( + f"orientation must be one of [{', '.join(default_dims.keys())}]." + ) diff --git a/mkdocs.yml b/mkdocs.yml index 8520fc3ddb..b72e1d4e98 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -116,6 +116,7 @@ nav: - geemap module: geemap.md - kepler module: kepler.md - legends module: legends.md + - map_widgets module: map_widgets.md - ml module: ml.md - osm module: osm.md - plot module: plot.md diff --git a/tests/test_map_widgets.py b/tests/test_map_widgets.py new file mode 100644 index 0000000000..caabe3e2e5 --- /dev/null +++ b/tests/test_map_widgets.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python + +"""Tests for `map_widgets` module.""" + + +import unittest +from unittest.mock import patch, MagicMock, ANY +from geemap import map_widgets + + +class TestColorbar(unittest.TestCase): + """Tests for the Colorbar class in the `map_widgets` module.""" + + TEST_COLORS = ["blue", "red", "green"] + TEST_COLORS_HEX = ["#0000ff", "#ff0000", "#008000"] + + def setUp(self): + self.fig_mock = MagicMock() + self.ax_mock = MagicMock() + self.subplots_mock = patch("matplotlib.pyplot.subplots").start() + self.subplots_mock.return_value = (self.fig_mock, self.ax_mock) + + self.colorbar_base_mock = MagicMock() + self.colorbar_base_class_mock = patch( + "matplotlib.colorbar.ColorbarBase" + ).start() + self.colorbar_base_class_mock.return_value = self.colorbar_base_mock + + self.normalize_mock = MagicMock() + self.normalize_class_mock = patch("matplotlib.colors.Normalize").start() + self.normalize_class_mock.return_value = self.normalize_mock + + self.boundary_norm_mock = MagicMock() + self.boundary_norm_class_mock = patch("matplotlib.colors.BoundaryNorm").start() + self.boundary_norm_class_mock.return_value = self.boundary_norm_mock + + self.listed_colormap = MagicMock() + self.listed_colormap_class_mock = patch( + "matplotlib.colors.ListedColormap" + ).start() + self.listed_colormap_class_mock.return_value = self.listed_colormap + + self.linear_segmented_colormap_mock = MagicMock() + self.colormap_from_list_mock = patch( + "matplotlib.colors.LinearSegmentedColormap.from_list" + ).start() + self.colormap_from_list_mock.return_value = self.linear_segmented_colormap_mock + + check_cmap_mock = patch("geemap.common.check_cmap").start() + check_cmap_mock.side_effect = lambda x: x + + self.cmap_mock = MagicMock() + self.get_cmap_mock = patch("matplotlib.pyplot.get_cmap").start() + self.get_cmap_mock.return_value = self.cmap_mock + + def tearDown(self): + patch.stopall() + + def test_colorbar_no_args(self): + map_widgets.Colorbar() + self.normalize_class_mock.assert_called_with(vmin=0, vmax=1) + self.get_cmap_mock.assert_called_with("gray") + self.subplots_mock.assert_called_with(figsize=(3.0, 0.3)) + self.ax_mock.set_axis_off.assert_not_called() + self.ax_mock.tick_params.assert_called_with(labelsize=9) + self.fig_mock.patch.set_alpha.assert_not_called() + self.colorbar_base_mock.set_label.assert_not_called() + self.colorbar_base_class_mock.assert_called_with( + self.ax_mock, + norm=self.normalize_mock, + alpha=1, + cmap=self.cmap_mock, + orientation="horizontal", + ) + + def test_colorbar_orientation_horizontal(self): + map_widgets.Colorbar(orientation="horizontal") + self.subplots_mock.assert_called_with(figsize=(3.0, 0.3)) + + def test_colorbar_orientation_vertical(self): + map_widgets.Colorbar(orientation="vertical") + self.subplots_mock.assert_called_with(figsize=(0.3, 3.0)) + + def test_colorbar_orientation_override(self): + map_widgets.Colorbar(orientation="horizontal", width=2.0) + self.subplots_mock.assert_called_with(figsize=(2.0, 0.3)) + + def test_colorbar_invalid_orientation(self): + with self.assertRaisesRegex(ValueError, "orientation must be one of"): + map_widgets.Colorbar(orientation="not an orientation") + + def test_colorbar_label(self): + map_widgets.Colorbar(label="Colorbar lbl", font_size=42) + self.colorbar_base_mock.set_label.assert_called_with( + "Colorbar lbl", fontsize=42 + ) + + def test_colorbar_label_as_bands(self): + map_widgets.Colorbar(vis_params={"bands": "b1"}) + self.colorbar_base_mock.set_label.assert_called_with("b1", fontsize=9) + + def test_colorbar_label_with_caption(self): + map_widgets.Colorbar(caption="Colorbar caption") + self.colorbar_base_mock.set_label.assert_called_with( + "Colorbar caption", fontsize=9 + ) + + def test_colorbar_label_precedence(self): + map_widgets.Colorbar( + label="Colorbar lbl", + vis_params={"bands": "b1"}, + caption="Colorbar caption", + font_size=21, + ) + self.colorbar_base_mock.set_label.assert_called_with( + "Colorbar lbl", fontsize=21 + ) + + def test_colorbar_axis(self): + map_widgets.Colorbar(axis_off=True, font_size=24) + self.ax_mock.set_axis_off.assert_called() + self.ax_mock.tick_params.assert_called_with(labelsize=24) + + def test_colorbar_transparent_bg(self): + map_widgets.Colorbar(transparent_bg=True) + self.fig_mock.patch.set_alpha.assert_called_with(0.0) + + def test_colorbar_vis_params_palette(self): + map_widgets.Colorbar( + vis_params={ + "palette": self.TEST_COLORS, + "min": 11, + "max": 21, + "opacity": 0.2, + } + ) + self.normalize_class_mock.assert_called_with(vmin=11, vmax=21) + self.colormap_from_list_mock.assert_called_with( + "custom", self.TEST_COLORS_HEX, N=256 + ) + self.colorbar_base_class_mock.assert_called_with( + self.ax_mock, + norm=self.normalize_mock, + alpha=0.2, + cmap=self.linear_segmented_colormap_mock, + orientation="horizontal", + ) + + def test_colorbar_vis_params_discrete_palette(self): + map_widgets.Colorbar( + vis_params={"palette": self.TEST_COLORS, "min": -1}, discrete=True + ) + self.boundary_norm_class_mock.assert_called_with([-1], ANY) + self.listed_colormap_class_mock.assert_called_with(self.TEST_COLORS_HEX) + self.colorbar_base_class_mock.assert_called_with( + self.ax_mock, + norm=self.boundary_norm_mock, + alpha=1, + cmap=self.listed_colormap, + orientation="horizontal", + ) + + def test_colorbar_vis_params_palette_as_list(self): + map_widgets.Colorbar(vis_params=self.TEST_COLORS, discrete=True) + self.boundary_norm_class_mock.assert_called_with([0], ANY) + self.listed_colormap_class_mock.assert_called_with(self.TEST_COLORS_HEX) + self.colorbar_base_class_mock.assert_called_with( + self.ax_mock, + norm=self.boundary_norm_mock, + alpha=1, + cmap=self.listed_colormap, + orientation="horizontal", + ) + + def test_colorbar_kwargs_colors(self): + map_widgets.Colorbar(colors=self.TEST_COLORS, discrete=True) + self.boundary_norm_class_mock.assert_called_with([0], ANY) + self.listed_colormap_class_mock.assert_called_with(self.TEST_COLORS_HEX) + self.colorbar_base_class_mock.assert_called_with( + self.ax_mock, + norm=self.boundary_norm_mock, + alpha=1, + cmap=self.listed_colormap, + orientation="horizontal", + colors=self.TEST_COLORS, + ) + + def test_colorbar_min_max(self): + map_widgets.Colorbar( + vis_params={"palette": self.TEST_COLORS, "min": -1.5}, vmin=-1, vmax=2 + ) + self.normalize_class_mock.assert_called_with(vmin=-1.5, vmax=1) + + def test_colorbar_invalid_min(self): + with self.assertRaisesRegex(TypeError, "min value must be scalar type"): + map_widgets.Colorbar(vis_params={"min": "invalid_min"}) + + def test_colorbar_invalid_max(self): + with self.assertRaisesRegex(TypeError, "max value must be scalar type"): + map_widgets.Colorbar(vis_params={"max": "invalid_max"}) + + def test_colorbar_opacity(self): + map_widgets.Colorbar(vis_params={"opacity": 0.5}, colors=self.TEST_COLORS) + self.colorbar_base_class_mock.assert_called_with( + ANY, norm=ANY, alpha=0.5, cmap=ANY, orientation=ANY, colors=ANY + ) + + def test_colorbar_alpha(self): + map_widgets.Colorbar(alpha=0.5, colors=self.TEST_COLORS) + self.colorbar_base_class_mock.assert_called_with( + ANY, norm=ANY, alpha=0.5, cmap=ANY, orientation=ANY, colors=ANY + ) + + def test_colorbar_invalid_alpha(self): + with self.assertRaisesRegex( + TypeError, "opacity or alpha value must be type scalar" + ): + map_widgets.Colorbar(alpha="invalid_alpha", colors=self.TEST_COLORS) + + def test_colorbar_vis_params_throws_for_not_dict(self): + with self.assertRaisesRegex(TypeError, "vis_params must be a dictionary"): + map_widgets.Colorbar(vis_params="NOT a dict")