From 47cf6d6e3cbc020ec776e2764c60f79c17cb5a5c Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sun, 12 Nov 2023 21:39:42 -0500 Subject: [PATCH] Add color schemes for visualizing vector data --- docs/map_widgets.md | 3 + docs/notebooks/84_read_parquet.ipynb | 40 +-- examples/notebooks/84_read_parquet.ipynb | 40 +-- leafmap/__init__.py | 53 +++- leafmap/common.py | 83 +++++- leafmap/deckgl.py | 97 +++++- leafmap/foliumap.py | 1 + leafmap/leafmap.py | 1 + leafmap/legends.py | 12 +- leafmap/map_widgets.py | 362 +++++++++++++++++++++++ mkdocs.yml | 1 + 11 files changed, 634 insertions(+), 59 deletions(-) create mode 100644 docs/map_widgets.md create mode 100644 leafmap/map_widgets.py diff --git a/docs/map_widgets.md b/docs/map_widgets.md new file mode 100644 index 0000000000..beb143cac9 --- /dev/null +++ b/docs/map_widgets.md @@ -0,0 +1,3 @@ +# map_widgets module + +::: leafmap.map_widgets diff --git a/docs/notebooks/84_read_parquet.ipynb b/docs/notebooks/84_read_parquet.ipynb index e73191187f..3cb1d73096 100644 --- a/docs/notebooks/84_read_parquet.ipynb +++ b/docs/notebooks/84_read_parquet.ipynb @@ -143,10 +143,21 @@ ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ - "Use DuckDB." + "color_map = {\n", + " \"Freshwater Forested/Shrub Wetland\": (0, 136, 55),\n", + " \"Freshwater Emergent Wetland\": (127, 195, 28),\n", + " \"Freshwater Pond\": (104, 140, 192),\n", + " \"Estuarine and Marine Wetland\": (102, 194, 165),\n", + " \"Riverine\": (1, 144, 191),\n", + " \"Lake\": (19, 0, 124),\n", + " \"Estuarine and Marine Deepwater\": (0, 124, 136),\n", + " \"Other Freshwater Wetland\": (178, 134, 86),\n", + " }" ] }, { @@ -155,21 +166,16 @@ "metadata": {}, "outputs": [], "source": [ - "import duckdb\n", - "import leafmap.deckgl as leafmap\n", - "\n", - "con = duckdb.connect()\n", - "con.install_extension(\"spatial\")\n", - "con.load_extension(\"spatial\")\n", - "\n", - "state = \"DC\" # Change to the US State of your choice\n", - "url = f\"https://data.source.coop/giswqs/nwi/wetlands/{state}_Wetlands.parquet\"\n", - "df = con.sql(f\"SELECT * EXCLUDE geometry, ST_AsText(ST_GeomFromWKB(geometry)) AS geometry FROM '{url}'\").df()\n", - "gdf = leafmap.df_to_gdf(df, src_crs=\"EPSG:5070\", dst_crs=\"EPSG:4326\")\n", - "\n", - "m = leafmap.Map()\n", - "m.add_gdf(gdf)\n", - "m" + "leafmap.view_vector(gdf, color_column='WETLAND_TYPE', color_map=color_map, opacity=0.5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "leafmap.Legend(title=\"Wetland Type\", legend_dict=color_map)" ] } ], diff --git a/examples/notebooks/84_read_parquet.ipynb b/examples/notebooks/84_read_parquet.ipynb index e73191187f..3cb1d73096 100644 --- a/examples/notebooks/84_read_parquet.ipynb +++ b/examples/notebooks/84_read_parquet.ipynb @@ -143,10 +143,21 @@ ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ - "Use DuckDB." + "color_map = {\n", + " \"Freshwater Forested/Shrub Wetland\": (0, 136, 55),\n", + " \"Freshwater Emergent Wetland\": (127, 195, 28),\n", + " \"Freshwater Pond\": (104, 140, 192),\n", + " \"Estuarine and Marine Wetland\": (102, 194, 165),\n", + " \"Riverine\": (1, 144, 191),\n", + " \"Lake\": (19, 0, 124),\n", + " \"Estuarine and Marine Deepwater\": (0, 124, 136),\n", + " \"Other Freshwater Wetland\": (178, 134, 86),\n", + " }" ] }, { @@ -155,21 +166,16 @@ "metadata": {}, "outputs": [], "source": [ - "import duckdb\n", - "import leafmap.deckgl as leafmap\n", - "\n", - "con = duckdb.connect()\n", - "con.install_extension(\"spatial\")\n", - "con.load_extension(\"spatial\")\n", - "\n", - "state = \"DC\" # Change to the US State of your choice\n", - "url = f\"https://data.source.coop/giswqs/nwi/wetlands/{state}_Wetlands.parquet\"\n", - "df = con.sql(f\"SELECT * EXCLUDE geometry, ST_AsText(ST_GeomFromWKB(geometry)) AS geometry FROM '{url}'\").df()\n", - "gdf = leafmap.df_to_gdf(df, src_crs=\"EPSG:5070\", dst_crs=\"EPSG:4326\")\n", - "\n", - "m = leafmap.Map()\n", - "m.add_gdf(gdf)\n", - "m" + "leafmap.view_vector(gdf, color_column='WETLAND_TYPE', color_map=color_map, opacity=0.5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "leafmap.Legend(title=\"Wetland Type\", legend_dict=color_map)" ] } ], diff --git a/leafmap/__init__.py b/leafmap/__init__.py index d17f8f0187..16e490ff04 100644 --- a/leafmap/__init__.py +++ b/leafmap/__init__.py @@ -26,23 +26,60 @@ def _use_folium(): return False -def view_vector(vector, zoom_to_layer=True, pickable=True, open_args={}, **kwargs): +def view_vector( + vector, + zoom_to_layer=True, + pickable=True, + color_column=None, + color_scheme="Quantiles", + color_map=None, + color_k=5, + color_args={}, + open_args={}, + map_args={}, + **kwargs, +): """Visualize a vector dataset on the map. Args: - vector (Union[str, GeoDataFrame]): The file path or URL to the vector data, or a GeoDataFrame. - zoom_to_layer (bool, optional): Flag to zoom to the added layer. Defaults to True. - pickable (bool, optional): Flag to enable picking on the added layer. Defaults to True. - open_args (dict, optional): Additional keyword arguments that will be passed to gpd.read_file() if vector is a file path or URL. Defaults to {}. - **kwargs: Additional keyword arguments that will be passed to the vector layer. + vector (Union[str, GeoDataFrame]): The file path or URL to the vector data, or a GeoDataFrame. + zoom_to_layer (bool, optional): Flag to zoom to the added layer. Defaults to True. + pickable (bool, optional): Flag to enable picking on the added layer. Defaults to True. + color_column (Optional[str], optional): The column to be used for color encoding. Defaults to None. + color_map (Optional[Union[str, Dict]], optional): The color map to use for color encoding. It can be a string or a dictionary. Defaults to None. + color_scheme (Optional[str], optional): The color scheme to use for color encoding. Defaults to "Quantiles". + Name of a choropleth classification scheme (requires mapclassify). + A mapclassify.MapClassifier object will be used + under the hood. Supported are all schemes provided by mapclassify (e.g. + 'BoxPlot', 'EqualInterval', 'FisherJenks', 'FisherJenksSampled', + 'HeadTailBreaks', 'JenksCaspall', 'JenksCaspallForced', + 'JenksCaspallSampled', 'MaxP', 'MaximumBreaks', + 'NaturalBreaks', 'Quantiles', 'Percentiles', 'StdMean', + 'UserDefined'). Arguments can be passed in classification_kwds. + color_k (Optional[int], optional): The number of classes to use for color encoding. Defaults to 5. + color_args (dict, optional): Additional keyword arguments that will be passed to assign_continuous_colors(). Defaults to {}. + open_args (dict, optional): Additional keyword arguments that will be passed to geopandas.read_file(). Defaults to {}. + map_args (dict, optional): Additional keyword arguments that will be passed to lonboard.Map. Defaults to {}. + **kwargs: Additional keyword arguments that will be passed to lonboard.Layer.from_geopandas() Returns: None """ from .deckgl import Map - m = Map() - m.add_vector(vector, zoom_to_layer, pickable, open_args, **kwargs) + m = Map(**map_args) + m.add_vector( + vector, + zoom_to_layer, + pickable, + color_column, + color_scheme, + color_map, + color_k, + color_args, + open_args, + **kwargs, + ) return m diff --git a/leafmap/common.py b/leafmap/common.py index c1fce14e51..85238d31be 100644 --- a/leafmap/common.py +++ b/leafmap/common.py @@ -468,7 +468,7 @@ def check_color(in_color: Union[str, Tuple]) -> str: """Checks the input color and returns the corresponding hex color code. Args: - in_color (str or tuple): It can be a string (e.g., 'red', '#ffff00', 'ffff00', 'ff0') or RGB tuple (e.g., (255, 127, 0)). + in_color (str or tuple or list): It can be a string (e.g., 'red', '#ffff00', 'ffff00', 'ff0') or RGB tuple (e.g., (255, 127, 0)). Returns: str: A hex color code. @@ -476,7 +476,9 @@ def check_color(in_color: Union[str, Tuple]) -> str: import colour out_color = "#000000" # default black color - if isinstance(in_color, tuple) and len(in_color) == 3: + if (isinstance(in_color, tuple) or isinstance(in_color, list)) and len( + in_color + ) == 3: # rescale color if necessary if all(isinstance(item, int) for item in in_color): in_color = [c / 255.0 for c in in_color] @@ -4918,7 +4920,11 @@ def classify( "mapclassify is required for this function. Install with `pip install mapclassify`." ) - if isinstance(data, gpd.GeoDataFrame) or isinstance(data, pd.DataFrame): + if ( + isinstance(data, gpd.GeoDataFrame) + or isinstance(data, pd.DataFrame) + or isinstance(data, pd.Series) + ): df = data else: try: @@ -11756,9 +11762,7 @@ def vector_to_parquet( gdf.to_parquet(output, **kwargs) -def df_to_gdf( - df, geometry="geometry", src_crs="EPSG:4326", dst_crs=None, **kwargs -): +def df_to_gdf(df, geometry="geometry", src_crs="EPSG:4326", dst_crs=None, **kwargs): """ Converts a pandas DataFrame to a GeoPandas GeoDataFrame. @@ -11779,7 +11783,7 @@ def df_to_gdf( # Convert the pandas DataFrame to a GeoPandas GeoDataFrame gdf = gpd.GeoDataFrame(df, geometry=geometry, crs=src_crs, **kwargs) - if dst_crs is not None and dst_crs != src_crs: + if dst_crs is not None and dst_crs != src_crs: gdf = gdf.to_crs(dst_crs) return gdf @@ -11911,3 +11915,68 @@ def read_parquet( con.close() return result + + +def assign_discrete_colors(df, column, cmap, to_rgb=True, return_type="array"): + """ + Assigns unique colors to each category in a categorical column of a dataframe. + + Args: + df (pandas.DataFrame): The input dataframe. + column (str): The name of the categorical column. + cmap (dict): A dictionary mapping categories to colors. + to_rgb (bool): Whether to convert the colors to RGB values. Defaults to True. + return_type (str): The type of the returned values. Can be 'list' or 'array'. Defaults to 'array'. + + Returns: + list: A list of colors for each category in the categorical column. + """ + import numpy as np + + # Copy the categorical column from the original dataframe + category_column = df[column].copy() + + # Map colors to the categorical values + category_column = category_column.map(cmap) + + values = category_column.values.tolist() + + if to_rgb: + values = [hex_to_rgb(check_color(color)) for color in values] + if return_type == "array": + values = np.array(values, dtype=np.uint8) + + return values + + +def assign_continuous_colors( + df, + column, + cmap=None, + colors=None, + labels=None, + scheme="Quantiles", + k=5, + legend_kwds=None, + classification_kwds=None, + to_rgb=True, + return_type="array", + return_legend=False, +): + import numpy as np + + data = df[[column]].copy() + new_df, legend = classify( + data, column, cmap, colors, labels, scheme, k, legend_kwds, classification_kwds + ) + values = new_df["color"].values.tolist() + + if to_rgb: + values = [hex_to_rgb(check_color(color)) for color in values] + if return_type == "array": + values = np.array(values, dtype=np.uint8) + + if return_legend: + return values, legend + else: + return values diff --git a/leafmap/deckgl.py b/leafmap/deckgl.py index c97ed043ca..7c583bb9ee 100644 --- a/leafmap/deckgl.py +++ b/leafmap/deckgl.py @@ -1,5 +1,6 @@ from typing import Union, List, Dict, Optional, Tuple, Any from .common import * +from .map_widgets import * try: import lonboard @@ -57,6 +58,11 @@ def add_gdf( gdf: gpd.GeoDataFrame, zoom_to_layer: bool = True, pickable: bool = True, + color_column: Optional[str] = None, + color_scheme: Optional[str] = "Quantiles", + color_map: Optional[Union[str, Dict]] = None, + color_k: Optional[int] = 5, + color_args: dict = {}, **kwargs: Any, ) -> None: """Adds a GeoPandas GeoDataFrame to the map. @@ -65,7 +71,20 @@ def add_gdf( gdf (GeoDataFrame): A GeoPandas GeoDataFrame with geometry column. zoom_to_layer (bool, optional): Flag to zoom to the added layer. Defaults to True. pickable (bool, optional): Flag to enable picking on the added layer. Defaults to True. - **kwargs: Additional keyword arguments that will be passed to the GeoDataFrame. + color_column (Optional[str], optional): The column to be used for color encoding. Defaults to None. + color_map (Optional[Union[str, Dict]], optional): The color map to use for color encoding. It can be a string or a dictionary. Defaults to None. + color_scheme (Optional[str], optional): The color scheme to use for color encoding. Defaults to "Quantiles". + Name of a choropleth classification scheme (requires mapclassify). + A mapclassify.MapClassifier object will be used + under the hood. Supported are all schemes provided by mapclassify (e.g. + 'BoxPlot', 'EqualInterval', 'FisherJenks', 'FisherJenksSampled', + 'HeadTailBreaks', 'JenksCaspall', 'JenksCaspallForced', + 'JenksCaspallSampled', 'MaxP', 'MaximumBreaks', + 'NaturalBreaks', 'Quantiles', 'Percentiles', 'StdMean', + 'UserDefined'). Arguments can be passed in classification_kwds. + color_k (Optional[int], optional): The number of classes to use for color encoding. Defaults to 5. + color_args (dict, optional): Additional keyword arguments that will be passed to assign_continuous_colors(). Defaults to {}. + **kwargs: Additional keyword arguments that will be passed to lonboard.Layer.from_geopandas() Returns: None @@ -79,14 +98,56 @@ def add_gdf( if geom_type in ["Point", "MultiPoint"]: if "get_radius" not in kwargs: kwargs["get_radius"] = 10 + if color_column is not None: + if isinstance(color_map, str): + kwargs["get_fill_color"] = assign_continuous_colors( + gdf, + color_column, + color_map, + scheme=color_scheme, + k=color_k, + **color_args, + ) + elif isinstance(color_map, dict): + kwargs["get_fill_color"] = assign_discrete_colors( + gdf, color_column, color_map, to_rgb=True, return_type="array" + ) if "get_fill_color" not in kwargs: kwargs["get_fill_color"] = [255, 0, 0, 180] layer = ScatterplotLayer.from_geopandas(gdf, **kwargs) elif geom_type in ["LineString", "MultiLineString"]: if "get_width" not in kwargs: kwargs["get_width"] = 5 + if color_column is not None: + if isinstance(color_map, str): + kwargs["get_color"] = assign_continuous_colors( + gdf, + color_column, + color_map, + scheme=color_scheme, + k=color_k, + **color_args, + ) + elif isinstance(color_map, dict): + kwargs["get_color"] = assign_discrete_colors( + gdf, color_column, color_map, to_rgb=True, return_type="array" + ) layer = PathLayer.from_geopandas(gdf, **kwargs) elif geom_type in ["Polygon", "MultiPolygon"]: + if color_column is not None: + if isinstance(color_map, str): + kwargs["get_fill_color"] = assign_continuous_colors( + gdf, + color_column, + color_map, + scheme=color_scheme, + k=color_k, + **color_args, + ) + elif isinstance(color_map, dict): + kwargs["get_fill_color"] = assign_discrete_colors( + gdf, color_column, color_map, to_rgb=True, return_type="array" + ) if "get_fill_color" not in kwargs: kwargs["get_fill_color"] = [0, 0, 255, 128] layer = SolidPolygonLayer.from_geopandas(gdf, **kwargs) @@ -103,6 +164,11 @@ def add_vector( vector: Union[str, gpd.GeoDataFrame], zoom_to_layer: bool = True, pickable: bool = True, + color_column: Optional[str] = None, + color_scheme: Optional[str] = "Quantiles", + color_map: Optional[Union[str, Dict]] = None, + color_k: Optional[int] = 5, + color_args: dict = {}, open_args: dict = {}, **kwargs: Any, ) -> None: @@ -112,8 +178,21 @@ def add_vector( vector (Union[str, GeoDataFrame]): The file path or URL to the vector data, or a GeoDataFrame. zoom_to_layer (bool, optional): Flag to zoom to the added layer. Defaults to True. pickable (bool, optional): Flag to enable picking on the added layer. Defaults to True. - open_args (dict, optional): Additional keyword arguments that will be passed to gpd.read_file() if vector is a file path or URL. Defaults to {}. - **kwargs: Additional keyword arguments that will be passed to the vector layer. + color_column (Optional[str], optional): The column to be used for color encoding. Defaults to None. + color_map (Optional[Union[str, Dict]], optional): The color map to use for color encoding. It can be a string or a dictionary. Defaults to None. + color_scheme (Optional[str], optional): The color scheme to use for color encoding. Defaults to "Quantiles". + Name of a choropleth classification scheme (requires mapclassify). + A mapclassify.MapClassifier object will be used + under the hood. Supported are all schemes provided by mapclassify (e.g. + 'BoxPlot', 'EqualInterval', 'FisherJenks', 'FisherJenksSampled', + 'HeadTailBreaks', 'JenksCaspall', 'JenksCaspallForced', + 'JenksCaspallSampled', 'MaxP', 'MaximumBreaks', + 'NaturalBreaks', 'Quantiles', 'Percentiles', 'StdMean', + 'UserDefined'). Arguments can be passed in classification_kwds. + color_k (Optional[int], optional): The number of classes to use for color encoding. Defaults to 5. + color_args (dict, optional): Additional keyword arguments that will be passed to assign_continuous_colors(). Defaults to {}. + open_args (dict, optional): Additional keyword arguments that will be passed to geopandas.read_file(). Defaults to {}. + **kwargs: Additional keyword arguments that will be passed to lonboard.Layer.from_geopandas() Returns: None @@ -123,7 +202,17 @@ def add_vector( gdf = vector else: gdf = gpd.read_file(vector, **open_args) - self.add_gdf(gdf, zoom_to_layer=zoom_to_layer, pickable=pickable, **kwargs) + self.add_gdf( + gdf, + zoom_to_layer, + pickable, + color_column, + color_scheme, + color_map, + color_k, + color_args, + **kwargs, + ) def add_layer( self, diff --git a/leafmap/foliumap.py b/leafmap/foliumap.py index a916213a0d..ad84808252 100644 --- a/leafmap/foliumap.py +++ b/leafmap/foliumap.py @@ -8,6 +8,7 @@ from .basemaps import xyz_to_folium from .osm import * from . import examples +from .map_widgets import * from branca.element import Figure, JavascriptLink, MacroElement from folium.elements import JSCSSMixin diff --git a/leafmap/leafmap.py b/leafmap/leafmap.py index 7a64345953..b8191f26c6 100644 --- a/leafmap/leafmap.py +++ b/leafmap/leafmap.py @@ -11,6 +11,7 @@ from .osm import * from .pc import * from . import examples +from .map_widgets import * basemaps = Box(xyz_to_leaflet(), frozen_box=True) diff --git a/leafmap/legends.py b/leafmap/legends.py index 29ad75f640..0d54b433ad 100644 --- a/leafmap/legends.py +++ b/leafmap/legends.py @@ -28,14 +28,14 @@ }, # National Wetlands Inventory Legend: https://www.fws.gov/wetlands/data/Mapper-Wetlands-Legend.html "NWI": { - "Freshwater- Forested and Shrub wetland": (0, 136, 55), - "Freshwater Emergent wetland": (127, 195, 28), - "Freshwater pond": (104, 140, 192), - "Estuarine and Marine wetland": (102, 194, 165), + "Freshwater Forested/Shrub Wetland": (0, 136, 55), + "Freshwater Emergent Wetland": (127, 195, 28), + "Freshwater Pond": (104, 140, 192), + "Estuarine and Marine Wetland": (102, 194, 165), "Riverine": (1, 144, 191), - "Lakes": (19, 0, 124), + "Lake": (19, 0, 124), "Estuarine and Marine Deepwater": (0, 124, 136), - "Other Freshwater wetland": (178, 134, 86), + "Other Freshwater Wetland": (178, 134, 86), }, # MCD12Q1.051 Land Cover Type Yearly Global 500m https://developers.google.com/earth-engine/datasets/catalog/MODIS_051_MCD12Q1 "MODIS/051/MCD12Q1": { diff --git a/leafmap/map_widgets.py b/leafmap/map_widgets.py new file mode 100644 index 0000000000..453b8d482a --- /dev/null +++ b/leafmap/map_widgets.py @@ -0,0 +1,362 @@ +import ipywidgets + +from . import common + + +class Colorbar(ipywidgets.Output): + """A matplotlib colorbar widget that can be added to the map.""" + + def __init__( + self, + vmin=0, + vmax=1, + 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 # noqa + 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 # noqa + 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 convertible to float. + ValueError: If the provided max value is not convertible to float. + ValueError: If the provided opacity value is not convertible to float. + 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" + + vis_params = { + "min": vmin, + "max": vmax, + } + + 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)) + try: + vmin = float(vmin) + except ValueError as err: + raise ValueError("The provided min value must be scalar type.") + + vmax = vis_params.get("max", kwargs.pop("vmax", 1)) + try: + vmax = float(vmax) + except ValueError as err: + raise ValueError("The provided max value must be scalar type.") + + alpha = vis_params.get("opacity", kwargs.pop("alpha", 1)) + try: + alpha = float(alpha) + except ValueError as err: + raise ValueError("opacity or alpha value must be scalar type.") + + 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())}]." + ) + + +class Legend(ipywidgets.VBox): + """A legend widget that can be added to the map.""" + + ALLOWED_POSITIONS = ["topleft", "topright", "bottomleft", "bottomright"] + DEFAULT_COLORS = ["#8DD3C7", "#FFFFB3", "#BEBADA", "#FB8072", "#80B1D3"] + DEFAULT_KEYS = ["One", "Two", "Three", "Four", "etc"] + DEFAULT_MAX_HEIGHT = "400px" + DEFAULT_MAX_WIDTH = "300px" + + def __init__( + self, + title="Legend", + legend_dict=None, + keys=None, + colors=None, + position="bottomright", + builtin_legend=None, + add_header=True, + widget_args={}, + **kwargs, + ): + """Adds a customized legend to the map. + + Args: + title (str, optional): Title of the legend. Defaults to 'Legend'. + legend_dict (dict, optional): A dictionary containing legend items + as keys and color as values. If provided, keys and colors will + be ignored. Defaults to None. + keys (list, optional): A list of legend keys. Defaults to None. + colors (list, optional): A list of legend colors. Defaults to None. + position (str, optional): Position of the legend. Defaults to + 'bottomright'. + builtin_legend (str, optional): Name of the builtin legend to add + to the map. Defaults to None. + add_header (bool, optional): Whether the legend can be closed or + not. Defaults to True. + widget_args (dict, optional): Additional arguments passed to the + widget_template() function. Defaults to {}. + + Raises: + TypeError: If the keys are not a list. + TypeError: If the colors are not list. + TypeError: If the colors are not a list of tuples. + TypeError: If the legend_dict is not a dictionary. + ValueError: If the legend template does not exist. + ValueError: If a rgb value cannot to be converted to hex. + ValueError: If the keys and colors are not the same length. + ValueError: If the builtin_legend is not allowed. + ValueError: If the position is not allowed. + + """ + import os # pylint: disable=import-outside-toplevel + from IPython.display import display # pylint: disable=import-outside-toplevel + import pkg_resources # pylint: disable=import-outside-toplevel + from .legends import builtin_legends # pylint: disable=import-outside-toplevel + + pkg_dir = os.path.dirname( + pkg_resources.resource_filename("leafmap", "leafmap.py") + ) + legend_template = os.path.join(pkg_dir, "data/template/legend.html") + + if not os.path.exists(legend_template): + raise ValueError("The legend template does not exist.") + + if "labels" in kwargs: + keys = kwargs["labels"] + kwargs.pop("labels") + + if keys is not None: + if not isinstance(keys, list): + raise TypeError("The legend keys must be a list.") + else: + keys = Legend.DEFAULT_KEYS + + if colors is not None: + if not isinstance(colors, list): + raise TypeError("The legend colors must be a list.") + elif all(isinstance(item, tuple) for item in colors): + colors = Legend.__convert_rgb_colors_to_hex(colors) + elif all((item.startswith("#") and len(item) == 7) for item in colors): + pass + elif all((len(item) == 6) for item in colors): + pass + else: + raise TypeError("The legend colors must be a list of tuples.") + else: + colors = Legend.DEFAULT_COLORS + + if len(keys) != len(colors): + raise ValueError("The legend keys and colors must be the same length.") + + allowed_builtin_legends = builtin_legends.keys() + if builtin_legend is not None: + builtin_legend_allowed = Legend.__check_if_allowed( + builtin_legend, "builtin legend", allowed_builtin_legends + ) + if builtin_legend_allowed: + legend_dict = builtin_legends[builtin_legend] + keys = list(legend_dict.keys()) + colors = list(legend_dict.values()) + + if legend_dict is not None: + if not isinstance(legend_dict, dict): + raise TypeError("The legend dict must be a dictionary.") + else: + keys = list(legend_dict.keys()) + colors = list(legend_dict.values()) + if all(isinstance(item, tuple) for item in colors): + colors = Legend.__convert_rgb_colors_to_hex(colors) + + Legend.__check_if_allowed(position, "position", Legend.ALLOWED_POSITIONS) + + header = [] + footer = [] + content = Legend.__create_legend_items(keys, colors) + + with open(legend_template) as f: + lines = f.readlines() + lines[3] = lines[3].replace("Legend", title) + header = lines[:6] + footer = lines[11:] + + legend_html = header + content + footer + legend_text = "".join(legend_html) + legend_output = ipywidgets.Output(layout=Legend.__create_layout(**kwargs)) + legend_widget = ipywidgets.HTML(value=legend_text) + + if add_header: + if "show_close_button" not in widget_args: + widget_args["show_close_button"] = False + if "widget_icon" not in widget_args: + widget_args["widget_icon"] = "bars" + + legend_output_widget = common.widget_template( + legend_output, + position=position, + display_widget=legend_widget, + **widget_args, + ) + else: + legend_output_widget = legend_widget + + super().__init__(children=[legend_output_widget]) + + legend_output.clear_output() + with legend_output: + display(legend_widget) + + def __check_if_allowed(value, value_name, allowed_list): + if value not in allowed_list: + raise ValueError( + "The " + + value_name + + " must be one of the following: {}.".format(", ".join(allowed_list)) + ) + return True + + def __convert_rgb_colors_to_hex(colors): + try: + return [common.rgb_to_hex(x) for x in colors] + except: + raise ValueError("Unable to convert rgb value to hex.") + + def __create_legend_items(keys, colors): + legend_items = [] + for index, key in enumerate(keys): + color = colors[index] + if not color.startswith("#"): + color = "#" + color + item = "
  • {}
  • \n".format( + color, key + ) + legend_items.append(item) + return legend_items + + def __create_layout(**kwargs): + height = Legend.__create_layout_property("height", None, **kwargs) + + min_height = Legend.__create_layout_property("min_height", None, **kwargs) + + if height is None: + max_height = Legend.DEFAULT_MAX_HEIGHT + else: + max_height = Legend.__create_layout_property("max_height", None, **kwargs) + + width = Legend.__create_layout_property("width", None, **kwargs) + + if "min_width" not in kwargs: + min_width = None + + if width is None: + max_width = Legend.DEFAULT_MAX_WIDTH + else: + max_width = Legend.__create_layout_property( + "max_width", Legend.DEFAULT_MAX_WIDTH, **kwargs + ) + + return { + "height": height, + "max_height": max_height, + "max_width": max_width, + "min_height": min_height, + "min_width": min_width, + "overflow": "scroll", + "width": width, + } + + def __create_layout_property(name, default_value, **kwargs): + return default_value if name not in kwargs else kwargs[name] diff --git a/mkdocs.yml b/mkdocs.yml index ac0938be0a..19f71d0652 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -116,6 +116,7 @@ nav: - kepler module: kepler.md - leafmap module: leafmap.md - legends module: legends.md + - map_widgets module: map_widgets.md - osm module: osm.md - pc module: pc.md - plotlymap module: plotlymap.md