From 34a920ed6870245541c2b335c4e4426ec77c6bb5 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Fri, 24 May 2024 15:23:42 +0200 Subject: [PATCH 01/28] first cut of lonboard drop-in --- ecoscope/mapping/lonboard_map.py | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 ecoscope/mapping/lonboard_map.py diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py new file mode 100644 index 00000000..d29b6ed0 --- /dev/null +++ b/ecoscope/mapping/lonboard_map.py @@ -0,0 +1,52 @@ +import ee +import geopandas as gpd +from lonboard import Map +from lonboard._layer import BaseLayer, BaseArrowLayer, BitmapTileLayer +from lonboard._deck_widget import BaseDeckWidget, NorthArrowWidget, ScaleWidget, LegendWidget, TitleWidget + + +class EcoMap(Map): + def __init__(self, static=False, *args, **kwargs): + super().__init__(*args, **kwargs) + + def add_layer(self, layer: BaseLayer): + self.layers = self.layers.copy().append(layer) + + def add_widget(self, widget: BaseDeckWidget): + self.deck_widgets = self.deck_widgets.copy().append(widget) + + def add_gdf(self, gdf: gpd.GeoDataFrame, **kwargs): + self.add_layer(BaseArrowLayer.from_geopandas(gdf=gdf, **kwargs)) + + def add_legend(self, **kwargs): + self.add_widget(LegendWidget(**kwargs)) + + def add_north_arrow(self, **kwargs): + self.add_widget(NorthArrowWidget(**kwargs)) + + def add_scale_bar(self, **kwargs): + self.add_widget(ScaleWidget(**kwargs)) + + def add_title(self, **kwargs): + self.add_widget(TitleWidget(**kwargs)) + + def add_ee_layer(self, ee_object, visualization_params, **kwargs): + if isinstance(ee_object, ee.image.Image): + map_id_dict = ee.Image(ee_object).getMapId(visualization_params) + ee_layer = BitmapTileLayer(data=map_id_dict["tile_fetcher"].url_format, **kwargs) + + elif isinstance(ee_object, ee.imagecollection.ImageCollection): + ee_object_new = ee_object.mosaic() + map_id_dict = ee.Image(ee_object_new).getMapId(visualization_params) + ee_layer = BitmapTileLayer(data=map_id_dict["tile_fetcher"].url_format, **kwargs) + + elif isinstance(ee_object, ee.geometry.Geometry): + gdf = gpd.GeoDataFrame([ee_object.toGeoJSON()]) + ee_layer = BaseArrowLayer.from_geopandas(gdf=gdf, **kwargs) + + elif isinstance(ee_object, ee.featurecollection.FeatureCollection): + ee_object_new = ee.Image().paint(ee_object, 0, 2) + map_id_dict = ee.Image(ee_object_new).getMapId(visualization_params) + ee_layer = BitmapTileLayer(data=map_id_dict["tile_fetcher"].url_format, **kwargs) + + self.add_layer(ee_layer) From 6bce59486bdc290f74ef0d304c5093e2e5f36312 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Tue, 28 May 2024 09:10:47 +0200 Subject: [PATCH 02/28] adding zoom_to_bounds --- ecoscope/mapping/__init__.py | 3 +++ ecoscope/mapping/lonboard_map.py | 26 +++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/ecoscope/mapping/__init__.py b/ecoscope/mapping/__init__.py index f4b16799..2e31271d 100644 --- a/ecoscope/mapping/__init__.py +++ b/ecoscope/mapping/__init__.py @@ -8,9 +8,12 @@ PrintControl, ) +from ecoscope.mapping.lonboard_map import EcoMap2 + __all__ = [ "ControlElement", "EcoMap", + "EcoMap2", "FloatElement", "NorthArrowElement", "ScaleElement", diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index d29b6ed0..42e5e5e2 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -1,11 +1,14 @@ import ee import geopandas as gpd +from typing import List, Union from lonboard import Map +from lonboard._geoarrow.ops.bbox import Bbox +from lonboard._viewport import compute_view, bbox_to_zoom_level from lonboard._layer import BaseLayer, BaseArrowLayer, BitmapTileLayer from lonboard._deck_widget import BaseDeckWidget, NorthArrowWidget, ScaleWidget, LegendWidget, TitleWidget -class EcoMap(Map): +class EcoMap2(Map): def __init__(self, static=False, *args, **kwargs): super().__init__(*args, **kwargs) @@ -50,3 +53,24 @@ def add_ee_layer(self, ee_object, visualization_params, **kwargs): ee_layer = BitmapTileLayer(data=map_id_dict["tile_fetcher"].url_format, **kwargs) self.add_layer(ee_layer) + + def zoom_to_bounds(self, feat: Union[List[BaseLayer], gpd.GeoDataFrame]): + if feat is None: + view_state = compute_view(self.layers) + elif isinstance(feat, List): + view_state = compute_view(feat) + else: + bounds = feat.to_crs(4326).total_bounds + bbox = Bbox(minx=bounds[0], miny=bounds[1], maxx=bounds[2], maxy=bounds[3]) + + centerLon = (bounds[0] + bounds[2]) / 2 + centerLat = (bounds[1] + bounds[3]) / 2 + + view_state = { + "longitude": centerLon, + "latitude": centerLat, + "zoom": bbox_to_zoom_level(bbox), + "pitch": 0, + "bearing": 0, + } + self.set_view_state(**view_state) From f3169a02de974ab5be42615fcfaf5128711a2cdf Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Tue, 18 Jun 2024 23:21:30 +1000 Subject: [PATCH 03/28] png widget + add local geotiff --- ecoscope/mapping/lonboard_map.py | 80 +++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index 42e5e5e2..eaed73a8 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -1,11 +1,22 @@ import ee +import base64 +import rasterio import geopandas as gpd +import matplotlib as mpl +import numpy as np from typing import List, Union from lonboard import Map from lonboard._geoarrow.ops.bbox import Bbox from lonboard._viewport import compute_view, bbox_to_zoom_level -from lonboard._layer import BaseLayer, BaseArrowLayer, BitmapTileLayer -from lonboard._deck_widget import BaseDeckWidget, NorthArrowWidget, ScaleWidget, LegendWidget, TitleWidget +from lonboard._layer import BaseLayer, BaseArrowLayer, BitmapLayer, BitmapTileLayer +from lonboard._deck_widget import ( + BaseDeckWidget, + NorthArrowWidget, + ScaleWidget, + LegendWidget, + TitleWidget, + SaveImageWidget, +) class EcoMap2(Map): @@ -33,6 +44,9 @@ def add_scale_bar(self, **kwargs): def add_title(self, **kwargs): self.add_widget(TitleWidget(**kwargs)) + def add_save_image(self, **kwargs): + self.add_widget(SaveImageWidget(**kwargs)) + def add_ee_layer(self, ee_object, visualization_params, **kwargs): if isinstance(ee_object, ee.image.Image): map_id_dict = ee.Image(ee_object).getMapId(visualization_params) @@ -74,3 +88,65 @@ def zoom_to_bounds(self, feat: Union[List[BaseLayer], gpd.GeoDataFrame]): "bearing": 0, } self.set_view_state(**view_state) + + def add_geotiff( + self, + path: str, + zoom: bool = False, + cmap: Union[str, mpl.colors.Colormap] = None, + colorbar: bool = True, + opacity: float = 0.7, + ): + with rasterio.open(path) as src: + transform, width, height = rasterio.warp.calculate_default_transform( + src.crs, "EPSG:4326", src.width, src.height, *src.bounds + ) + rio_kwargs = src.meta.copy() + rio_kwargs.update({"crs": "EPSG:4326", "transform": transform, "width": width, "height": height}) + + # new + bounds = rasterio.warp.transform_bounds(src.crs, "EPSG:4326", *src.bounds) + + if cmap is None: + im = [rasterio.band(src, i + 1) for i in range(src.count)] + else: + cmap = mpl.cm.get_cmap(cmap) + rio_kwargs["count"] = 4 + im = rasterio.band(src, 1)[0].read()[0] + im_min, im_max = np.nanmin(im), np.nanmax(im) + im = np.rollaxis(cmap((im - im_min) / (im_max - im_min), bytes=True), -1) + # if colorbar: + # if isinstance(im_min, np.integer) and im_max - im_min < 256: + # self.add_child( + # StepColormap( + # [mpl.colors.rgb2hex(color) for color in cmap(np.linspace(0, 1, 1 + im_max - im_min))], + # index=np.arange(1 + im_max - im_min), + # vmin=im_min, + # vmax=im_max + 1, + # ) + # ) + # else: + # self.add_child( + # StepColormap( + # [mpl.colors.rgb2hex(color) for color in cmap(np.linspace(0, 1, 256))], + # vmin=im_min, + # vmax=im_max, + # ) + # ) + + with rasterio.io.MemoryFile() as memfile: + with memfile.open(**rio_kwargs) as dst: + for i in range(rio_kwargs["count"]): + rasterio.warp.reproject( + source=im[i], + destination=rasterio.band(dst, i + 1), + src_transform=src.transform, + src_crs=src.crs, + dst_transform=transform, + dst_crs="EPSG:4326", + resampling=rasterio.warp.Resampling.nearest, + ) + dst.bounds + url = "data:image/tiff;base64," + base64.b64encode(memfile.read()).decode("utf-8") + + self.add_layer(BitmapLayer(image=url, bounds=bounds, opacity=opacity)) From a24d0fd5db25c63becdd1e81c40386d6cec586dd Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Wed, 19 Jun 2024 14:14:55 +1000 Subject: [PATCH 04/28] fix zoom to bounds flow --- ecoscope/mapping/lonboard_map.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index eaed73a8..4cedf99b 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -68,12 +68,10 @@ def add_ee_layer(self, ee_object, visualization_params, **kwargs): self.add_layer(ee_layer) - def zoom_to_bounds(self, feat: Union[List[BaseLayer], gpd.GeoDataFrame]): + def zoom_to_bounds(self, feat: Union[BaseLayer, List[BaseLayer], gpd.GeoDataFrame]): if feat is None: view_state = compute_view(self.layers) - elif isinstance(feat, List): - view_state = compute_view(feat) - else: + elif isinstance(feat, gpd.GeoDataFrame): bounds = feat.to_crs(4326).total_bounds bbox = Bbox(minx=bounds[0], miny=bounds[1], maxx=bounds[2], maxy=bounds[3]) @@ -87,6 +85,9 @@ def zoom_to_bounds(self, feat: Union[List[BaseLayer], gpd.GeoDataFrame]): "pitch": 0, "bearing": 0, } + else: + view_state = compute_view(feat) + self.set_view_state(**view_state) def add_geotiff( From 7c3d7977a093564c4bf3b91378152f15c3a6be9e Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Thu, 20 Jun 2024 12:22:39 +1000 Subject: [PATCH 05/28] first cut convert tiff to png --- ecoscope/mapping/lonboard_map.py | 34 ++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index 4cedf99b..a268316c 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -24,10 +24,14 @@ def __init__(self, static=False, *args, **kwargs): super().__init__(*args, **kwargs) def add_layer(self, layer: BaseLayer): - self.layers = self.layers.copy().append(layer) + update = self.layers.copy() + update.append(layer) + self.layers = update def add_widget(self, widget: BaseDeckWidget): - self.deck_widgets = self.deck_widgets.copy().append(widget) + update = self.deck_widgets.copy() + update.append(widget) + self.deck_widgets = update def add_gdf(self, gdf: gpd.GeoDataFrame, **kwargs): self.add_layer(BaseArrowLayer.from_geopandas(gdf=gdf, **kwargs)) @@ -147,7 +151,25 @@ def add_geotiff( dst_crs="EPSG:4326", resampling=rasterio.warp.Resampling.nearest, ) - dst.bounds - url = "data:image/tiff;base64," + base64.b64encode(memfile.read()).decode("utf-8") - - self.add_layer(BitmapLayer(image=url, bounds=bounds, opacity=opacity)) + height = dst.height + width = dst.width + + data = dst.read( + out_dtype=rasterio.uint8, + out_shape=(rio_kwargs["count"], int(height), int(width)), + resampling=rasterio.enums.Resampling.bilinear, + ) + + with rasterio.io.MemoryFile() as outfile: + with outfile.open( + driver="PNG", + height=data.shape[1], + width=data.shape[2], + count=rio_kwargs["count"], + dtype=data.dtype, + ) as mempng: + mempng.write(data) + url = "data:image/png;base64," + base64.b64encode(outfile.read()).decode("utf-8") + + layer = BitmapLayer(image=url, bounds=bounds, opacity=opacity) + self.add_layer(layer) From 23a693b25e39b5d72c2fe44e1e3513190be68053 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Thu, 20 Jun 2024 17:49:49 +1000 Subject: [PATCH 06/28] fix ee.Geometry path --- ecoscope/mapping/lonboard_map.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index a268316c..17b7f36f 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -1,6 +1,7 @@ import ee import base64 import rasterio +import json import geopandas as gpd import matplotlib as mpl import numpy as np @@ -62,7 +63,8 @@ def add_ee_layer(self, ee_object, visualization_params, **kwargs): ee_layer = BitmapTileLayer(data=map_id_dict["tile_fetcher"].url_format, **kwargs) elif isinstance(ee_object, ee.geometry.Geometry): - gdf = gpd.GeoDataFrame([ee_object.toGeoJSON()]) + geojson = ee_object.toGeoJSON() + gdf = gpd.read_file(json.dumps(geojson), driver="GeoJSON") ee_layer = BaseArrowLayer.from_geopandas(gdf=gdf, **kwargs) elif isinstance(ee_object, ee.featurecollection.FeatureCollection): From f79abac62ef4cf260835fa4cce35f2b86bf77469 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Fri, 21 Jun 2024 13:35:45 +1000 Subject: [PATCH 07/28] cleanup and static mode --- ecoscope/mapping/lonboard_map.py | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index 17b7f36f..09b7fcf3 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -22,6 +22,13 @@ class EcoMap2(Map): def __init__(self, static=False, *args, **kwargs): + + kwargs["height"] = kwargs.get("height", 600) + kwargs["width"] = kwargs.get("width", 800) + + if static: + kwargs["controller"] = False + super().__init__(*args, **kwargs) def add_layer(self, layer: BaseLayer): @@ -101,7 +108,6 @@ def add_geotiff( path: str, zoom: bool = False, cmap: Union[str, mpl.colors.Colormap] = None, - colorbar: bool = True, opacity: float = 0.7, ): with rasterio.open(path) as src: @@ -122,24 +128,7 @@ def add_geotiff( im = rasterio.band(src, 1)[0].read()[0] im_min, im_max = np.nanmin(im), np.nanmax(im) im = np.rollaxis(cmap((im - im_min) / (im_max - im_min), bytes=True), -1) - # if colorbar: - # if isinstance(im_min, np.integer) and im_max - im_min < 256: - # self.add_child( - # StepColormap( - # [mpl.colors.rgb2hex(color) for color in cmap(np.linspace(0, 1, 1 + im_max - im_min))], - # index=np.arange(1 + im_max - im_min), - # vmin=im_min, - # vmax=im_max + 1, - # ) - # ) - # else: - # self.add_child( - # StepColormap( - # [mpl.colors.rgb2hex(color) for color in cmap(np.linspace(0, 1, 256))], - # vmin=im_min, - # vmax=im_max, - # ) - # ) + # TODO Handle Colorbar widget with rasterio.io.MemoryFile() as memfile: with memfile.open(**rio_kwargs) as dst: From 86a06a5a13c499ee64e3db63c3afb983240ff126 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Sat, 22 Jun 2024 00:50:57 +1000 Subject: [PATCH 08/28] some integration tweaks --- ecoscope/mapping/lonboard_map.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index 09b7fcf3..76403dc1 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -5,11 +5,12 @@ import geopandas as gpd import matplotlib as mpl import numpy as np -from typing import List, Union +from typing import Dict, List, Union from lonboard import Map from lonboard._geoarrow.ops.bbox import Bbox from lonboard._viewport import compute_view, bbox_to_zoom_level -from lonboard._layer import BaseLayer, BaseArrowLayer, BitmapLayer, BitmapTileLayer +from lonboard._layer import BaseLayer, BitmapLayer, BitmapTileLayer +from lonboard._viz import create_layers_from_data_input from lonboard._deck_widget import ( BaseDeckWidget, NorthArrowWidget, @@ -31,9 +32,11 @@ def __init__(self, static=False, *args, **kwargs): super().__init__(*args, **kwargs) - def add_layer(self, layer: BaseLayer): + def add_layer(self, layer: Union[BaseLayer, List[BaseLayer]]): update = self.layers.copy() - update.append(layer) + if not isinstance(layer, list): + layer = [layer] + update.extend(layer) self.layers = update def add_widget(self, widget: BaseDeckWidget): @@ -42,7 +45,7 @@ def add_widget(self, widget: BaseDeckWidget): self.deck_widgets = update def add_gdf(self, gdf: gpd.GeoDataFrame, **kwargs): - self.add_layer(BaseArrowLayer.from_geopandas(gdf=gdf, **kwargs)) + self.add_layer(create_layers_from_data_input(data=gdf, **kwargs)) def add_legend(self, **kwargs): self.add_widget(LegendWidget(**kwargs)) @@ -59,7 +62,12 @@ def add_title(self, **kwargs): def add_save_image(self, **kwargs): self.add_widget(SaveImageWidget(**kwargs)) - def add_ee_layer(self, ee_object, visualization_params, **kwargs): + def add_ee_layer( + self, + ee_object: Union[ee.Image, ee.ImageCollection, ee.Geometry, ee.FeatureCollection], + visualization_params: Dict, + **kwargs + ): if isinstance(ee_object, ee.image.Image): map_id_dict = ee.Image(ee_object).getMapId(visualization_params) ee_layer = BitmapTileLayer(data=map_id_dict["tile_fetcher"].url_format, **kwargs) @@ -72,7 +80,8 @@ def add_ee_layer(self, ee_object, visualization_params, **kwargs): elif isinstance(ee_object, ee.geometry.Geometry): geojson = ee_object.toGeoJSON() gdf = gpd.read_file(json.dumps(geojson), driver="GeoJSON") - ee_layer = BaseArrowLayer.from_geopandas(gdf=gdf, **kwargs) + color = kwargs.pop("color", "#00FFFF") + ee_layer = create_layers_from_data_input(data=gdf, _viz_color=color, **kwargs) elif isinstance(ee_object, ee.featurecollection.FeatureCollection): ee_object_new = ee.Image().paint(ee_object, 0, 2) From 19b47ce494e6e27826e7e41c49cdfacbfb259d5f Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Mon, 24 Jun 2024 09:39:26 +1000 Subject: [PATCH 09/28] support named xyz services dict from contrib --- ecoscope/mapping/lonboard_map.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index 76403dc1..75e03c5f 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -11,6 +11,7 @@ from lonboard._viewport import compute_view, bbox_to_zoom_level from lonboard._layer import BaseLayer, BitmapLayer, BitmapTileLayer from lonboard._viz import create_layers_from_data_input +from ecoscope.contrib.basemaps import xyz_tiles from lonboard._deck_widget import ( BaseDeckWidget, NorthArrowWidget, @@ -27,6 +28,8 @@ def __init__(self, static=False, *args, **kwargs): kwargs["height"] = kwargs.get("height", 600) kwargs["width"] = kwargs.get("width", 800) + kwargs["layers"] = kwargs.get("layers", [self.get_named_tile_layer("HYBRID")]) + if static: kwargs["controller"] = False @@ -173,3 +176,15 @@ def add_geotiff( layer = BitmapLayer(image=url, bounds=bounds, opacity=opacity) self.add_layer(layer) + + @staticmethod + def get_named_tile_layer(layer: str = "HYBRID") -> BitmapTileLayer: + layer = xyz_tiles.get(layer) + if not layer: + raise ValueError("string layer name must be in {}".format(", ".join(xyz_tiles.keys()))) + return BitmapTileLayer( + data=layer.get("url"), + tile_size=layer.get("tile_size", 128), + max_zoom=layer.get("max_zoom", None), + min_zoom=layer.get("min_zoom", None), + ) From 4e92d2a423363d80c7e95c3dd75e0024fdaf6621 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Mon, 24 Jun 2024 13:39:06 +1000 Subject: [PATCH 10/28] use viz_layer --- ecoscope/mapping/lonboard_map.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index 75e03c5f..dc5610a5 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -10,7 +10,7 @@ from lonboard._geoarrow.ops.bbox import Bbox from lonboard._viewport import compute_view, bbox_to_zoom_level from lonboard._layer import BaseLayer, BitmapLayer, BitmapTileLayer -from lonboard._viz import create_layers_from_data_input +from lonboard._viz import viz_layer from ecoscope.contrib.basemaps import xyz_tiles from lonboard._deck_widget import ( BaseDeckWidget, @@ -48,7 +48,7 @@ def add_widget(self, widget: BaseDeckWidget): self.deck_widgets = update def add_gdf(self, gdf: gpd.GeoDataFrame, **kwargs): - self.add_layer(create_layers_from_data_input(data=gdf, **kwargs)) + self.add_layer(viz_layer(data=gdf, **kwargs)) def add_legend(self, **kwargs): self.add_widget(LegendWidget(**kwargs)) @@ -83,8 +83,7 @@ def add_ee_layer( elif isinstance(ee_object, ee.geometry.Geometry): geojson = ee_object.toGeoJSON() gdf = gpd.read_file(json.dumps(geojson), driver="GeoJSON") - color = kwargs.pop("color", "#00FFFF") - ee_layer = create_layers_from_data_input(data=gdf, _viz_color=color, **kwargs) + ee_layer = viz_layer(data=gdf, **kwargs) elif isinstance(ee_object, ee.featurecollection.FeatureCollection): ee_object_new = ee.Image().paint(ee_object, 0, 2) From ba62ec53bf240a53f929776be127943f853788b4 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Mon, 24 Jun 2024 14:31:09 +1000 Subject: [PATCH 11/28] first pass of explore() overide change --- ecoscope/__init__.py | 12 +++--------- ecoscope/mapping/lonboard_map.py | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/ecoscope/__init__.py b/ecoscope/__init__.py index 75eb7297..e3b6e007 100644 --- a/ecoscope/__init__.py +++ b/ecoscope/__init__.py @@ -59,17 +59,11 @@ def explore(data, *args, **kwargs): """ from ecoscope import mapping - initialized = "m" in kwargs - if not initialized: - kwargs["m"] = mapping.EcoMap() - - if isinstance(kwargs["m"], mapping.EcoMap): - m = kwargs.pop("m") + try: + m = mapping.EcoMap2() m.add_gdf(data, *args, **kwargs) - if not initialized: - m.zoom_to_bounds(data.geometry.to_crs(4326).total_bounds) return m - else: + except Exception: return gpd.explore._explore(data, *args, **kwargs) gpd.GeoDataFrame.explore = explore diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index dc5610a5..a5bc7925 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -5,6 +5,7 @@ import geopandas as gpd import matplotlib as mpl import numpy as np +import pandas as pd from typing import Dict, List, Union from lonboard import Map from lonboard._geoarrow.ops.bbox import Bbox @@ -47,8 +48,20 @@ def add_widget(self, widget: BaseDeckWidget): update.append(widget) self.deck_widgets = update - def add_gdf(self, gdf: gpd.GeoDataFrame, **kwargs): - self.add_layer(viz_layer(data=gdf, **kwargs)) + def add_gdf(self, data: Union[gpd.GeoDataFrame, gpd.GeoSeries], zoom: bool = True, **kwargs): + data = data.copy() + data = data.to_crs(4326) + data = data.loc[(~data.geometry.isna()) & (~data.geometry.is_empty)] + + if isinstance(data, gpd.GeoDataFrame): + for col in data: + if pd.api.types.is_datetime64_any_dtype(data[col]): + data[col] = data[col].astype("string") + + self.add_layer(viz_layer(data=data, **kwargs)) + + if zoom: + self.zoom_to_bounds(data) def add_legend(self, **kwargs): self.add_widget(LegendWidget(**kwargs)) From 573d64f5142e16a8cb71c2c2152e1e5b8c1a9ade Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Mon, 24 Jun 2024 15:36:39 +1000 Subject: [PATCH 12/28] named tile layer changes --- ecoscope/mapping/lonboard_map.py | 44 +++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index a5bc7925..5b29af51 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -12,7 +12,6 @@ from lonboard._viewport import compute_view, bbox_to_zoom_level from lonboard._layer import BaseLayer, BitmapLayer, BitmapTileLayer from lonboard._viz import viz_layer -from ecoscope.contrib.basemaps import xyz_tiles from lonboard._deck_widget import ( BaseDeckWidget, NorthArrowWidget, @@ -20,20 +19,24 @@ LegendWidget, TitleWidget, SaveImageWidget, + FullscreenWidget, ) class EcoMap2(Map): - def __init__(self, static=False, *args, **kwargs): + def __init__(self, static=False, default_widgets=True, *args, **kwargs): kwargs["height"] = kwargs.get("height", 600) kwargs["width"] = kwargs.get("width", 800) - kwargs["layers"] = kwargs.get("layers", [self.get_named_tile_layer("HYBRID")]) + kwargs["layers"] = kwargs.get("layers", [self.get_named_tile_layer("OpenStreetMap")]) if static: kwargs["controller"] = False + if kwargs.get("deck_widgets") is None and default_widgets: + kwargs["deck_widgets"] = [FullscreenWidget(), ScaleWidget(), SaveImageWidget()] + super().__init__(*args, **kwargs) def add_layer(self, layer: Union[BaseLayer, List[BaseLayer]]): @@ -190,7 +193,39 @@ def add_geotiff( self.add_layer(layer) @staticmethod - def get_named_tile_layer(layer: str = "HYBRID") -> BitmapTileLayer: + def get_named_tile_layer(layer: str) -> BitmapTileLayer: + + # From Leafmap + # https://github.com/opengeos/leafmap/blob/master/leafmap/basemaps.py + xyz_tiles = { + "OpenStreetMap": { + "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "attribution": "OpenStreetMap", + "name": "OpenStreetMap", + "max_requests": -1, + }, + "ROADMAP": { + "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}", # noqa + "attribution": "Esri", + "name": "Esri.WorldStreetMap", + }, + "SATELLITE": { + "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + "attribution": "Esri", + "name": "Esri.WorldImagery", + }, + "TERRAIN": { + "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", + "attribution": "Esri", + "name": "Esri.WorldTopoMap", + }, + "HYBRID": { + "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + "attribution": "Esri", + "name": "Esri.WorldImagery", + }, + } + layer = xyz_tiles.get(layer) if not layer: raise ValueError("string layer name must be in {}".format(", ".join(xyz_tiles.keys()))) @@ -199,4 +234,5 @@ def get_named_tile_layer(layer: str = "HYBRID") -> BitmapTileLayer: tile_size=layer.get("tile_size", 128), max_zoom=layer.get("max_zoom", None), min_zoom=layer.get("min_zoom", None), + max_requests=layer.get("max_requests", None), ) From f0185104b0c280db7c171a587678ea8da31af217 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Mon, 24 Jun 2024 22:44:06 +1000 Subject: [PATCH 13/28] speedmap port --- ecoscope/mapping/lonboard_map.py | 42 +++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index 5b29af51..7fdf0206 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -7,11 +7,13 @@ import numpy as np import pandas as pd from typing import Dict, List, Union +from ecoscope.analysis.speed import SpeedDataFrame from lonboard import Map from lonboard._geoarrow.ops.bbox import Bbox from lonboard._viewport import compute_view, bbox_to_zoom_level from lonboard._layer import BaseLayer, BitmapLayer, BitmapTileLayer from lonboard._viz import viz_layer +from lonboard.colormap import apply_categorical_cmap from lonboard._deck_widget import ( BaseDeckWidget, NorthArrowWidget, @@ -23,7 +25,42 @@ ) -class EcoMap2(Map): +class EcoMapMixin: + def add_speedmap( + self, + trajectory: gpd.GeoDataFrame, + classification_method: str = "equal_interval", + num_classes: int = 6, + speed_colors: List = None, + bins: List = None, + legend: bool = True, + ): + + speed_df = SpeedDataFrame.from_trajectory( + trajectory=trajectory, + classification_method=classification_method, + num_classes=num_classes, + speed_colors=speed_colors, + bins=bins, + ) + + colors = speed_df["speed_colour"].to_list() + rgb = [] + for i, color in enumerate(colors): + color = color.strip("#") + rgb.append(list(int(color[i : i + 2], 16) for i in (0, 2, 4))) + + cmap = apply_categorical_cmap(values=speed_df.index.to_series(), cmap=rgb) + path_kwargs = {"get_color": cmap, "pickable": False} + self.add_gdf(speed_df, path_kwargs=path_kwargs) + + if legend: + self.add_legend(labels=speed_df.label.to_list(), colors=speed_df.speed_colour.to_list()) + + return speed_df + + +class EcoMap2(EcoMapMixin, Map): def __init__(self, static=False, default_widgets=True, *args, **kwargs): kwargs["height"] = kwargs.get("height", 600) @@ -76,6 +113,9 @@ def add_scale_bar(self, **kwargs): self.add_widget(ScaleWidget(**kwargs)) def add_title(self, **kwargs): + kwargs["placement"] = kwargs.get("placement", "fill") + kwargs["style"] = kwargs.get("style", {"position": "relative", "margin": "0 auto", "width": "35%"}) + self.add_widget(TitleWidget(**kwargs)) def add_save_image(self, **kwargs): From 1abf2c1f6381235112f1c72e7991fd7c859e7a44 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Tue, 25 Jun 2024 00:57:00 +1000 Subject: [PATCH 14/28] add docs for new ecomap --- ecoscope/mapping/lonboard_map.py | 134 ++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 4 deletions(-) diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index 7fdf0206..7819d2eb 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -76,19 +76,45 @@ def __init__(self, static=False, default_widgets=True, *args, **kwargs): super().__init__(*args, **kwargs) - def add_layer(self, layer: Union[BaseLayer, List[BaseLayer]]): + def add_layer(self, layer: Union[BaseLayer, List[BaseLayer]], zoom: bool = False): + """ + Adds a layer or list of layers to the map + Parameters + ---------- + layer : lonboard.BaseLayer or list[lonboard.BaseLayer] + zoom: bool + Whether to zoom the map to the new layer + """ update = self.layers.copy() if not isinstance(layer, list): layer = [layer] update.extend(layer) self.layers = update + if zoom: + self.zoom_to_bounds(layer) def add_widget(self, widget: BaseDeckWidget): + """ + Adds a deck widget to the map + Parameters + ---------- + widget : lonboard.BaseDeckWidget or list[lonboard.BaseDeckWidget] + """ update = self.deck_widgets.copy() update.append(widget) self.deck_widgets = update def add_gdf(self, data: Union[gpd.GeoDataFrame, gpd.GeoSeries], zoom: bool = True, **kwargs): + """ + Visualize a gdf on the map, results in one or more layers being added + Parameters + ---------- + data : lonboard.BaseDeckWidget or list[lonboard.BaseDeckWidget] + zoom : bool + Whether or not to zoom the map to the bounds of the data + kwargs: + Additional kwargs passed to lonboard.viz_layer + """ data = data.copy() data = data.to_crs(4326) data = data.loc[(~data.geometry.isna()) & (~data.geometry.is_empty)] @@ -104,21 +130,77 @@ def add_gdf(self, data: Union[gpd.GeoDataFrame, gpd.GeoSeries], zoom: bool = Tru self.zoom_to_bounds(data) def add_legend(self, **kwargs): + """ + Adds a legend to the map + Parameters + ---------- + placement: str + One of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" + Where to place the widget within the map + title: str + A title displayed on the widget + labels: list[str] + A list of labels + colors: list[str] + A list of colors as hex values + style: dict + Additional style params + """ self.add_widget(LegendWidget(**kwargs)) def add_north_arrow(self, **kwargs): + """ + Adds a north arrow to the map + Parameters + ---------- + placement: str, one of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" + Where to place the widget within the map + style: dict + Additional style params + """ self.add_widget(NorthArrowWidget(**kwargs)) def add_scale_bar(self, **kwargs): + """ + Adds a scale bar to the map + Parameters + ---------- + placement: str, one of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" + Where to place the widget within the map + use_imperial: bool + If true, show scale in miles/ft, rather than m/km + style: dict + Additional style params + """ self.add_widget(ScaleWidget(**kwargs)) def add_title(self, **kwargs): + """ + Adds a title to the map + Parameters + ---------- + placement: str, one of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" + Where to place the widget within the map + title: str + The map title + style: dict + Additional style params + """ kwargs["placement"] = kwargs.get("placement", "fill") kwargs["style"] = kwargs.get("style", {"position": "relative", "margin": "0 auto", "width": "35%"}) self.add_widget(TitleWidget(**kwargs)) def add_save_image(self, **kwargs): + """ + Adds a button to save the map as a png + Parameters + ---------- + placement: str, one of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" + Where to place the widget within the map + style: dict + Additional style params + """ self.add_widget(SaveImageWidget(**kwargs)) def add_ee_layer( @@ -127,6 +209,27 @@ def add_ee_layer( visualization_params: Dict, **kwargs ): + """ + Adds a provided Earth Engine object to the map. + If an EE.Image/EE.ImageCollection or EE.FeatureCollection is provided, + this results in a BitmapTileLayer being added + + For EE.Geometry objects, a list of ScatterplotLayer,PathLayer and PolygonLayer will be added + based on the geometry itself (defers to lonboard.viz) + + Parameters + ---------- + ee_object: ee.Image, ee.ImageCollection, ee.Geometry, ee.FeatureCollection] + The ee object to represent as a layer + visualization_params: dict + Visualization params passed to EarthEngine + kwargs + Additional params passed to either lonboard.BitmapTileLayer or lonboard.viz + + Returns + ------- + None + """ if isinstance(ee_object, ee.image.Image): map_id_dict = ee.Image(ee_object).getMapId(visualization_params) ee_layer = BitmapTileLayer(data=map_id_dict["tile_fetcher"].url_format, **kwargs) @@ -149,6 +252,14 @@ def add_ee_layer( self.add_layer(ee_layer) def zoom_to_bounds(self, feat: Union[BaseLayer, List[BaseLayer], gpd.GeoDataFrame]): + """ + Zooms the map to the bounds of a dataframe or layer. + + Parameters + ---------- + feat : BaseLayer, List[lonboard.BaseLayer], gpd.GeoDataFrame + The feature to zoom to + """ if feat is None: view_state = compute_view(self.layers) elif isinstance(feat, gpd.GeoDataFrame): @@ -177,6 +288,22 @@ def add_geotiff( cmap: Union[str, mpl.colors.Colormap] = None, opacity: float = 0.7, ): + """ + Adds a local geotiff to the map + Note that since deck.gl tiff support is limited, this extracts the CRS/Bounds from the tiff + and converts the image data in-memory to PNG + + Parameters + ---------- + path : str + The path to the local tiff + zoom : bool + Whether to zoom the map to the bounds of the tiff + cmap: str or matplotlib.colors.Colormap + The colormap to apply to the raster + opacity: float + The opacity of the overlay + """ with rasterio.open(path) as src: transform, width, height = rasterio.warp.calculate_default_transform( src.crs, "EPSG:4326", src.width, src.height, *src.bounds @@ -195,7 +322,7 @@ def add_geotiff( im = rasterio.band(src, 1)[0].read()[0] im_min, im_max = np.nanmin(im), np.nanmax(im) im = np.rollaxis(cmap((im - im_min) / (im_max - im_min), bytes=True), -1) - # TODO Handle Colorbar widget + # TODO Handle Colorbar with rasterio.io.MemoryFile() as memfile: with memfile.open(**rio_kwargs) as dst: @@ -230,11 +357,10 @@ def add_geotiff( url = "data:image/png;base64," + base64.b64encode(outfile.read()).decode("utf-8") layer = BitmapLayer(image=url, bounds=bounds, opacity=opacity) - self.add_layer(layer) + self.add_layer(layer, zoom=zoom) @staticmethod def get_named_tile_layer(layer: str) -> BitmapTileLayer: - # From Leafmap # https://github.com/opengeos/leafmap/blob/master/leafmap/basemaps.py xyz_tiles = { From 2cea59b66faf573b0372ef2b214d6b6fa0cdcb2e Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Tue, 25 Jun 2024 16:30:06 +1000 Subject: [PATCH 15/28] tests and pil support --- ecoscope/mapping/lonboard_map.py | 38 +++++-- tests/test_ecomap2.py | 164 +++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 tests/test_ecomap2.py diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index 7819d2eb..346d3f58 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -6,6 +6,7 @@ import matplotlib as mpl import numpy as np import pandas as pd +from io import BytesIO from typing import Dict, List, Union from ecoscope.analysis.speed import SpeedDataFrame from lonboard import Map @@ -68,12 +69,15 @@ def __init__(self, static=False, default_widgets=True, *args, **kwargs): kwargs["layers"] = kwargs.get("layers", [self.get_named_tile_layer("OpenStreetMap")]) + if kwargs.get("deck_widgets") is None and default_widgets: + if static: + kwargs["deck_widgets"] = [ScaleWidget()] + else: + kwargs["deck_widgets"] = [FullscreenWidget(), ScaleWidget(), SaveImageWidget()] + if static: kwargs["controller"] = False - if kwargs.get("deck_widgets") is None and default_widgets: - kwargs["deck_widgets"] = [FullscreenWidget(), ScaleWidget(), SaveImageWidget()] - super().__init__(*args, **kwargs) def add_layer(self, layer: Union[BaseLayer, List[BaseLayer]], zoom: bool = False): @@ -174,18 +178,17 @@ def add_scale_bar(self, **kwargs): """ self.add_widget(ScaleWidget(**kwargs)) - def add_title(self, **kwargs): + def add_title(self, title: str, **kwargs): """ Adds a title to the map Parameters ---------- - placement: str, one of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" - Where to place the widget within the map title: str The map title style: dict Additional style params """ + kwargs["title"] = title kwargs["placement"] = kwargs.get("placement", "fill") kwargs["style"] = kwargs.get("style", {"position": "relative", "margin": "0 auto", "width": "35%"}) @@ -359,6 +362,29 @@ def add_geotiff( layer = BitmapLayer(image=url, bounds=bounds, opacity=opacity) self.add_layer(layer, zoom=zoom) + def add_pil_image(self, image, bounds, zoom=True, opacity=1): + """ + Overlays a PIL.Image onto the Ecomap + + Parameters + ---------- + image : PIL.Image + The image to be overlaid + bounds: tuple + Tuple containing the EPSG:4326 (minx, miny, maxx, maxy) values bounding the given image + zoom : bool, optional + Zoom to the generated image + opacity : float, optional + Sets opacity of overlaid image + """ + + data = BytesIO() + image.save(data, "PNG") + + url = "data:image/png;base64," + base64.b64encode(data.getvalue()).decode("utf-8") + layer = BitmapLayer(image=url, bounds=bounds.tolist(), opacity=opacity) + self.add_layer(layer, zoom=zoom) + @staticmethod def get_named_tile_layer(layer: str) -> BitmapTileLayer: # From Leafmap diff --git a/tests/test_ecomap2.py b/tests/test_ecomap2.py new file mode 100644 index 00000000..06eb4360 --- /dev/null +++ b/tests/test_ecomap2.py @@ -0,0 +1,164 @@ +import ee +import geopandas as gpd +import pytest +from ecoscope.mapping.lonboard_map import EcoMap2 +from ecoscope.analysis.geospatial import datashade_gdf +from lonboard._layer import BitmapLayer, BitmapTileLayer, PolygonLayer +from lonboard._deck_widget import ( + NorthArrowWidget, + ScaleWidget, + TitleWidget, + SaveImageWidget, + FullscreenWidget, +) + + +def test_ecomap_base(): + m = EcoMap2() + + assert len(m.deck_widgets) == 3 + assert len(m.layers) == 1 + assert isinstance(m.layers[0], BitmapTileLayer) + assert isinstance(m.deck_widgets[0], FullscreenWidget) + assert isinstance(m.deck_widgets[1], ScaleWidget) + assert isinstance(m.deck_widgets[2], SaveImageWidget) + + +def test_static_map(): + m = EcoMap2(static=True) + + assert m.controller is False + assert len(m.deck_widgets) == 1 + assert len(m.layers) == 1 + assert isinstance(m.layers[0], BitmapTileLayer) + assert isinstance(m.deck_widgets[0], ScaleWidget) + + +def test_add_legend(): + m = EcoMap2(default_widgets=False) + m.add_legend(labels=["Black", "White"], colors=["#000000", "#FFFFFF"]) + assert len(m.deck_widgets) == 1 + + +def test_add_north_arrow(): + m = EcoMap2() + m.add_north_arrow() + assert len(m.deck_widgets) == 4 + assert isinstance(m.deck_widgets[3], NorthArrowWidget) + + +def test_add_scale_bar(): + m = EcoMap2() + m.add_scale_bar() + assert len(m.deck_widgets) == 4 + assert isinstance(m.deck_widgets[3], ScaleWidget) + + +def test_add_title(): + m = EcoMap2() + m.add_title("THIS IS A TEST TITLE") + assert len(m.deck_widgets) == 4 + assert isinstance(m.deck_widgets[3], TitleWidget) + + +def test_add_save_image(): + m = EcoMap2() + m.add_save_image() + assert len(m.deck_widgets) == 4 + assert isinstance(m.deck_widgets[3], SaveImageWidget) + + +@pytest.mark.skipif(not pytest.earthengine, reason="No connection to EarthEngine.") +def test_add_ee_layer_image(): + m = EcoMap2() + vis_params = {"min": 0, "max": 4000, "opacity": 0.5, "palette": ["006633", "E5FFCC", "662A00", "D8D8D8", "F5F5F5"]} + ee_object = ee.Image("USGS/SRTMGL1_003") + m.add_ee_layer(ee_object, vis_params) + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapTileLayer) + + +@pytest.mark.skipif(not pytest.earthengine, reason="No connection to EarthEngine.") +def test_add_ee_layer_image_collection(): + m = EcoMap2() + vis_params = {"min": 0, "max": 4000, "opacity": 0.5} + ee_object = ee.ImageCollection("MODIS/006/MCD43C3") + m.add_ee_layer(ee_object, vis_params) + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapTileLayer) + + +@pytest.mark.skipif(not pytest.earthengine, reason="No onnection to EarthEngine.") +def test_add_ee_layer_feature_collection(): + m = EcoMap2() + vis_params = {"min": 0, "max": 4000, "opacity": 0.5, "palette": ["006633", "E5FFCC", "662A00", "D8D8D8", "F5F5F5"]} + ee_object = ee.FeatureCollection("LARSE/GEDI/GEDI02_A_002/GEDI02_A_2021244154857_O15413_04_T05622_02_003_02_V002") + m.add_ee_layer(ee_object, vis_params) + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapTileLayer) + + +@pytest.mark.skipif(not pytest.earthengine, reason="No connection to EarthEngine.") +def test_add_ee_layer_geometry(): + m = EcoMap2() + rectangle = ee.Geometry.Rectangle([-40, -20, 40, 20]) + m.add_ee_layer(rectangle, None) + assert len(m.layers) == 2 + assert isinstance(m.layers[1], PolygonLayer) + + +def test_zoom_to_gdf(): + m = EcoMap2() + x1 = 34.683838 + y1 = -3.173425 + x2 = 38.869629 + y2 = 0.109863 + gs = gpd.GeoSeries.from_wkt( + [f"POINT ({x1} {y1})", f"POINT ({x2} {y1})", f"POINT ({x1} {y2})", f"POINT ({x2} {y2})"] + ) + gs = gs.set_crs("EPSG:4326") + m.zoom_to_bounds(feat=gpd.GeoDataFrame(geometry=gs)) + + assert m.view_state.longitude == (x1 + x2) / 2 + assert m.view_state.latitude == (y1 + y2) / 2 + + +def test_add_geotiff(): + m = EcoMap2() + m.add_geotiff("tests/sample_data/raster/uint8.tif", cmap=None) + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapLayer) + + +def test_add_geotiff_with_cmap(): + m = EcoMap2() + m.add_geotiff("tests/sample_data/raster/uint8.tif", cmap="jet") + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapLayer) + + +@pytest.mark.parametrize( + "file, geom_type", + [ + ("tests/sample_data/vector/maec_4zones_UTM36S.gpkg", "polygon"), + ("tests/sample_data/vector/observations.geojson", "point"), + ], +) +def test_add_datashader_gdf(file, geom_type): + m = EcoMap2() + gdf = gpd.GeoDataFrame.from_file(file) + img, bounds = datashade_gdf(gdf, geom_type) + m.add_pil_image(img, bounds, zoom=False) + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapLayer) + + +def test_add_datashader_gdf_with_zoom(): + m = EcoMap2() + gdf = gpd.GeoDataFrame.from_file("tests/sample_data/vector/maec_4zones_UTM36S.gpkg") + img, bounds = datashade_gdf(gdf, "polygon") + m.add_pil_image(img, bounds) + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapLayer) + assert m.view_state.longitude == (bounds[0] + bounds[2]) / 2 + assert m.view_state.latitude == (bounds[1] + bounds[3]) / 2 From 43a311748ede67d31498176f1c23b5f066e851c8 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Tue, 25 Jun 2024 21:56:56 +1000 Subject: [PATCH 16/28] fix breaking change in matplotlib 3.9.0 --- ecoscope/mapping/lonboard_map.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py index 346d3f58..bb246b6f 100644 --- a/ecoscope/mapping/lonboard_map.py +++ b/ecoscope/mapping/lonboard_map.py @@ -320,7 +320,7 @@ def add_geotiff( if cmap is None: im = [rasterio.band(src, i + 1) for i in range(src.count)] else: - cmap = mpl.cm.get_cmap(cmap) + cmap = mpl.colormaps[cmap] rio_kwargs["count"] = 4 im = rasterio.band(src, 1)[0].read()[0] im_min, im_max = np.nanmin(im), np.nanmax(im) From e51425e88981e03801c31c06b8740114cee39e68 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Tue, 25 Jun 2024 22:13:28 +1000 Subject: [PATCH 17/28] add lonboard fork as dep --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 1a3d148b..58c6e664 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,7 @@ "igraph", "ipywidgets", "kaleido", + "lonboard @ git+https://github.com/wildlife-dynamics/lonboard@77c56d30a9c2dd96fd863e910bf62952cfa36da8", "mapclassify", "matplotlib", "networkx", From d77d7e908ac343aa0126feb4c3e8fdaa65d4195f Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Tue, 25 Jun 2024 22:30:43 +1000 Subject: [PATCH 18/28] replace folium ecomap with lonboard --- ecoscope/mapping/__init__.py | 19 +- ecoscope/mapping/lonboard_map.py | 430 --------------- ecoscope/mapping/map.py | 919 ++++++++++--------------------- tests/test_ecomap.py | 180 +++--- tests/test_ecomap2.py | 164 ------ 5 files changed, 360 insertions(+), 1352 deletions(-) delete mode 100644 ecoscope/mapping/lonboard_map.py delete mode 100644 tests/test_ecomap2.py diff --git a/ecoscope/mapping/__init__.py b/ecoscope/mapping/__init__.py index 2e31271d..15e70d93 100644 --- a/ecoscope/mapping/__init__.py +++ b/ecoscope/mapping/__init__.py @@ -1,22 +1,5 @@ -from ecoscope.mapping.map import ( - ControlElement, - EcoMap, - FloatElement, - NorthArrowElement, - ScaleElement, - GeoTIFFElement, - PrintControl, -) - -from ecoscope.mapping.lonboard_map import EcoMap2 +from ecoscope.mapping.map import EcoMap __all__ = [ - "ControlElement", "EcoMap", - "EcoMap2", - "FloatElement", - "NorthArrowElement", - "ScaleElement", - "GeoTIFFElement", - "PrintControl", ] diff --git a/ecoscope/mapping/lonboard_map.py b/ecoscope/mapping/lonboard_map.py deleted file mode 100644 index bb246b6f..00000000 --- a/ecoscope/mapping/lonboard_map.py +++ /dev/null @@ -1,430 +0,0 @@ -import ee -import base64 -import rasterio -import json -import geopandas as gpd -import matplotlib as mpl -import numpy as np -import pandas as pd -from io import BytesIO -from typing import Dict, List, Union -from ecoscope.analysis.speed import SpeedDataFrame -from lonboard import Map -from lonboard._geoarrow.ops.bbox import Bbox -from lonboard._viewport import compute_view, bbox_to_zoom_level -from lonboard._layer import BaseLayer, BitmapLayer, BitmapTileLayer -from lonboard._viz import viz_layer -from lonboard.colormap import apply_categorical_cmap -from lonboard._deck_widget import ( - BaseDeckWidget, - NorthArrowWidget, - ScaleWidget, - LegendWidget, - TitleWidget, - SaveImageWidget, - FullscreenWidget, -) - - -class EcoMapMixin: - def add_speedmap( - self, - trajectory: gpd.GeoDataFrame, - classification_method: str = "equal_interval", - num_classes: int = 6, - speed_colors: List = None, - bins: List = None, - legend: bool = True, - ): - - speed_df = SpeedDataFrame.from_trajectory( - trajectory=trajectory, - classification_method=classification_method, - num_classes=num_classes, - speed_colors=speed_colors, - bins=bins, - ) - - colors = speed_df["speed_colour"].to_list() - rgb = [] - for i, color in enumerate(colors): - color = color.strip("#") - rgb.append(list(int(color[i : i + 2], 16) for i in (0, 2, 4))) - - cmap = apply_categorical_cmap(values=speed_df.index.to_series(), cmap=rgb) - path_kwargs = {"get_color": cmap, "pickable": False} - self.add_gdf(speed_df, path_kwargs=path_kwargs) - - if legend: - self.add_legend(labels=speed_df.label.to_list(), colors=speed_df.speed_colour.to_list()) - - return speed_df - - -class EcoMap2(EcoMapMixin, Map): - def __init__(self, static=False, default_widgets=True, *args, **kwargs): - - kwargs["height"] = kwargs.get("height", 600) - kwargs["width"] = kwargs.get("width", 800) - - kwargs["layers"] = kwargs.get("layers", [self.get_named_tile_layer("OpenStreetMap")]) - - if kwargs.get("deck_widgets") is None and default_widgets: - if static: - kwargs["deck_widgets"] = [ScaleWidget()] - else: - kwargs["deck_widgets"] = [FullscreenWidget(), ScaleWidget(), SaveImageWidget()] - - if static: - kwargs["controller"] = False - - super().__init__(*args, **kwargs) - - def add_layer(self, layer: Union[BaseLayer, List[BaseLayer]], zoom: bool = False): - """ - Adds a layer or list of layers to the map - Parameters - ---------- - layer : lonboard.BaseLayer or list[lonboard.BaseLayer] - zoom: bool - Whether to zoom the map to the new layer - """ - update = self.layers.copy() - if not isinstance(layer, list): - layer = [layer] - update.extend(layer) - self.layers = update - if zoom: - self.zoom_to_bounds(layer) - - def add_widget(self, widget: BaseDeckWidget): - """ - Adds a deck widget to the map - Parameters - ---------- - widget : lonboard.BaseDeckWidget or list[lonboard.BaseDeckWidget] - """ - update = self.deck_widgets.copy() - update.append(widget) - self.deck_widgets = update - - def add_gdf(self, data: Union[gpd.GeoDataFrame, gpd.GeoSeries], zoom: bool = True, **kwargs): - """ - Visualize a gdf on the map, results in one or more layers being added - Parameters - ---------- - data : lonboard.BaseDeckWidget or list[lonboard.BaseDeckWidget] - zoom : bool - Whether or not to zoom the map to the bounds of the data - kwargs: - Additional kwargs passed to lonboard.viz_layer - """ - data = data.copy() - data = data.to_crs(4326) - data = data.loc[(~data.geometry.isna()) & (~data.geometry.is_empty)] - - if isinstance(data, gpd.GeoDataFrame): - for col in data: - if pd.api.types.is_datetime64_any_dtype(data[col]): - data[col] = data[col].astype("string") - - self.add_layer(viz_layer(data=data, **kwargs)) - - if zoom: - self.zoom_to_bounds(data) - - def add_legend(self, **kwargs): - """ - Adds a legend to the map - Parameters - ---------- - placement: str - One of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" - Where to place the widget within the map - title: str - A title displayed on the widget - labels: list[str] - A list of labels - colors: list[str] - A list of colors as hex values - style: dict - Additional style params - """ - self.add_widget(LegendWidget(**kwargs)) - - def add_north_arrow(self, **kwargs): - """ - Adds a north arrow to the map - Parameters - ---------- - placement: str, one of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" - Where to place the widget within the map - style: dict - Additional style params - """ - self.add_widget(NorthArrowWidget(**kwargs)) - - def add_scale_bar(self, **kwargs): - """ - Adds a scale bar to the map - Parameters - ---------- - placement: str, one of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" - Where to place the widget within the map - use_imperial: bool - If true, show scale in miles/ft, rather than m/km - style: dict - Additional style params - """ - self.add_widget(ScaleWidget(**kwargs)) - - def add_title(self, title: str, **kwargs): - """ - Adds a title to the map - Parameters - ---------- - title: str - The map title - style: dict - Additional style params - """ - kwargs["title"] = title - kwargs["placement"] = kwargs.get("placement", "fill") - kwargs["style"] = kwargs.get("style", {"position": "relative", "margin": "0 auto", "width": "35%"}) - - self.add_widget(TitleWidget(**kwargs)) - - def add_save_image(self, **kwargs): - """ - Adds a button to save the map as a png - Parameters - ---------- - placement: str, one of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" - Where to place the widget within the map - style: dict - Additional style params - """ - self.add_widget(SaveImageWidget(**kwargs)) - - def add_ee_layer( - self, - ee_object: Union[ee.Image, ee.ImageCollection, ee.Geometry, ee.FeatureCollection], - visualization_params: Dict, - **kwargs - ): - """ - Adds a provided Earth Engine object to the map. - If an EE.Image/EE.ImageCollection or EE.FeatureCollection is provided, - this results in a BitmapTileLayer being added - - For EE.Geometry objects, a list of ScatterplotLayer,PathLayer and PolygonLayer will be added - based on the geometry itself (defers to lonboard.viz) - - Parameters - ---------- - ee_object: ee.Image, ee.ImageCollection, ee.Geometry, ee.FeatureCollection] - The ee object to represent as a layer - visualization_params: dict - Visualization params passed to EarthEngine - kwargs - Additional params passed to either lonboard.BitmapTileLayer or lonboard.viz - - Returns - ------- - None - """ - if isinstance(ee_object, ee.image.Image): - map_id_dict = ee.Image(ee_object).getMapId(visualization_params) - ee_layer = BitmapTileLayer(data=map_id_dict["tile_fetcher"].url_format, **kwargs) - - elif isinstance(ee_object, ee.imagecollection.ImageCollection): - ee_object_new = ee_object.mosaic() - map_id_dict = ee.Image(ee_object_new).getMapId(visualization_params) - ee_layer = BitmapTileLayer(data=map_id_dict["tile_fetcher"].url_format, **kwargs) - - elif isinstance(ee_object, ee.geometry.Geometry): - geojson = ee_object.toGeoJSON() - gdf = gpd.read_file(json.dumps(geojson), driver="GeoJSON") - ee_layer = viz_layer(data=gdf, **kwargs) - - elif isinstance(ee_object, ee.featurecollection.FeatureCollection): - ee_object_new = ee.Image().paint(ee_object, 0, 2) - map_id_dict = ee.Image(ee_object_new).getMapId(visualization_params) - ee_layer = BitmapTileLayer(data=map_id_dict["tile_fetcher"].url_format, **kwargs) - - self.add_layer(ee_layer) - - def zoom_to_bounds(self, feat: Union[BaseLayer, List[BaseLayer], gpd.GeoDataFrame]): - """ - Zooms the map to the bounds of a dataframe or layer. - - Parameters - ---------- - feat : BaseLayer, List[lonboard.BaseLayer], gpd.GeoDataFrame - The feature to zoom to - """ - if feat is None: - view_state = compute_view(self.layers) - elif isinstance(feat, gpd.GeoDataFrame): - bounds = feat.to_crs(4326).total_bounds - bbox = Bbox(minx=bounds[0], miny=bounds[1], maxx=bounds[2], maxy=bounds[3]) - - centerLon = (bounds[0] + bounds[2]) / 2 - centerLat = (bounds[1] + bounds[3]) / 2 - - view_state = { - "longitude": centerLon, - "latitude": centerLat, - "zoom": bbox_to_zoom_level(bbox), - "pitch": 0, - "bearing": 0, - } - else: - view_state = compute_view(feat) - - self.set_view_state(**view_state) - - def add_geotiff( - self, - path: str, - zoom: bool = False, - cmap: Union[str, mpl.colors.Colormap] = None, - opacity: float = 0.7, - ): - """ - Adds a local geotiff to the map - Note that since deck.gl tiff support is limited, this extracts the CRS/Bounds from the tiff - and converts the image data in-memory to PNG - - Parameters - ---------- - path : str - The path to the local tiff - zoom : bool - Whether to zoom the map to the bounds of the tiff - cmap: str or matplotlib.colors.Colormap - The colormap to apply to the raster - opacity: float - The opacity of the overlay - """ - with rasterio.open(path) as src: - transform, width, height = rasterio.warp.calculate_default_transform( - src.crs, "EPSG:4326", src.width, src.height, *src.bounds - ) - rio_kwargs = src.meta.copy() - rio_kwargs.update({"crs": "EPSG:4326", "transform": transform, "width": width, "height": height}) - - # new - bounds = rasterio.warp.transform_bounds(src.crs, "EPSG:4326", *src.bounds) - - if cmap is None: - im = [rasterio.band(src, i + 1) for i in range(src.count)] - else: - cmap = mpl.colormaps[cmap] - rio_kwargs["count"] = 4 - im = rasterio.band(src, 1)[0].read()[0] - im_min, im_max = np.nanmin(im), np.nanmax(im) - im = np.rollaxis(cmap((im - im_min) / (im_max - im_min), bytes=True), -1) - # TODO Handle Colorbar - - with rasterio.io.MemoryFile() as memfile: - with memfile.open(**rio_kwargs) as dst: - for i in range(rio_kwargs["count"]): - rasterio.warp.reproject( - source=im[i], - destination=rasterio.band(dst, i + 1), - src_transform=src.transform, - src_crs=src.crs, - dst_transform=transform, - dst_crs="EPSG:4326", - resampling=rasterio.warp.Resampling.nearest, - ) - height = dst.height - width = dst.width - - data = dst.read( - out_dtype=rasterio.uint8, - out_shape=(rio_kwargs["count"], int(height), int(width)), - resampling=rasterio.enums.Resampling.bilinear, - ) - - with rasterio.io.MemoryFile() as outfile: - with outfile.open( - driver="PNG", - height=data.shape[1], - width=data.shape[2], - count=rio_kwargs["count"], - dtype=data.dtype, - ) as mempng: - mempng.write(data) - url = "data:image/png;base64," + base64.b64encode(outfile.read()).decode("utf-8") - - layer = BitmapLayer(image=url, bounds=bounds, opacity=opacity) - self.add_layer(layer, zoom=zoom) - - def add_pil_image(self, image, bounds, zoom=True, opacity=1): - """ - Overlays a PIL.Image onto the Ecomap - - Parameters - ---------- - image : PIL.Image - The image to be overlaid - bounds: tuple - Tuple containing the EPSG:4326 (minx, miny, maxx, maxy) values bounding the given image - zoom : bool, optional - Zoom to the generated image - opacity : float, optional - Sets opacity of overlaid image - """ - - data = BytesIO() - image.save(data, "PNG") - - url = "data:image/png;base64," + base64.b64encode(data.getvalue()).decode("utf-8") - layer = BitmapLayer(image=url, bounds=bounds.tolist(), opacity=opacity) - self.add_layer(layer, zoom=zoom) - - @staticmethod - def get_named_tile_layer(layer: str) -> BitmapTileLayer: - # From Leafmap - # https://github.com/opengeos/leafmap/blob/master/leafmap/basemaps.py - xyz_tiles = { - "OpenStreetMap": { - "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", - "attribution": "OpenStreetMap", - "name": "OpenStreetMap", - "max_requests": -1, - }, - "ROADMAP": { - "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}", # noqa - "attribution": "Esri", - "name": "Esri.WorldStreetMap", - }, - "SATELLITE": { - "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", - "attribution": "Esri", - "name": "Esri.WorldImagery", - }, - "TERRAIN": { - "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", - "attribution": "Esri", - "name": "Esri.WorldTopoMap", - }, - "HYBRID": { - "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", - "attribution": "Esri", - "name": "Esri.WorldImagery", - }, - } - - layer = xyz_tiles.get(layer) - if not layer: - raise ValueError("string layer name must be in {}".format(", ".join(xyz_tiles.keys()))) - return BitmapTileLayer( - data=layer.get("url"), - tile_size=layer.get("tile_size", 128), - max_zoom=layer.get("max_zoom", None), - min_zoom=layer.get("min_zoom", None), - max_requests=layer.get("max_requests", None), - ) diff --git a/ecoscope/mapping/map.py b/ecoscope/mapping/map.py index 3a4e04aa..63ec35b8 100644 --- a/ecoscope/mapping/map.py +++ b/ecoscope/mapping/map.py @@ -1,26 +1,29 @@ -import base64 -import os -import time -import typing -import urllib -import warnings - - import ee -import folium +import base64 +import rasterio +import json import geopandas as gpd import matplotlib as mpl import numpy as np import pandas as pd -import rasterio -import selenium.webdriver -from branca.colormap import StepColormap -from branca.element import MacroElement, Template - -from ecoscope.contrib.foliumap import Map +from io import BytesIO +from typing import Dict, List, Union from ecoscope.analysis.speed import SpeedDataFrame - -warnings.filterwarnings("ignore", "GeoSeries.isna", UserWarning) +from lonboard import Map +from lonboard._geoarrow.ops.bbox import Bbox +from lonboard._viewport import compute_view, bbox_to_zoom_level +from lonboard._layer import BaseLayer, BitmapLayer, BitmapTileLayer +from lonboard._viz import viz_layer +from lonboard.colormap import apply_categorical_cmap +from lonboard._deck_widget import ( + BaseDeckWidget, + NorthArrowWidget, + ScaleWidget, + LegendWidget, + TitleWidget, + SaveImageWidget, + FullscreenWidget, +) class EcoMapMixin: @@ -29,8 +32,8 @@ def add_speedmap( trajectory: gpd.GeoDataFrame, classification_method: str = "equal_interval", num_classes: int = 6, - speed_colors: typing.List = None, - bins: typing.List = None, + speed_colors: List = None, + bins: List = None, legend: bool = True, ): @@ -41,416 +44,292 @@ def add_speedmap( speed_colors=speed_colors, bins=bins, ) - self.add_gdf(speed_df, color=speed_df["speed_colour"]) + + colors = speed_df["speed_colour"].to_list() + rgb = [] + for i, color in enumerate(colors): + color = color.strip("#") + rgb.append(list(int(color[i : i + 2], 16) for i in (0, 2, 4))) + + cmap = apply_categorical_cmap(values=speed_df.index.to_series(), cmap=rgb) + path_kwargs = {"get_color": cmap, "pickable": False} + self.add_gdf(speed_df, path_kwargs=path_kwargs) if legend: - self.add_legend(legend_dict=dict(zip(speed_df.label, speed_df.speed_colour))) + self.add_legend(labels=speed_df.label.to_list(), colors=speed_df.speed_colour.to_list()) return speed_df class EcoMap(EcoMapMixin, Map): - def __init__(self, *args, static=False, print_control=True, **kwargs): - kwargs["attr"] = kwargs.get("attr", " ") - kwargs["canvas"] = kwargs.get("canvas", True) - kwargs["control_scale"] = kwargs.get("control_scale", False) + def __init__(self, static=False, default_widgets=True, *args, **kwargs): + kwargs["height"] = kwargs.get("height", 600) kwargs["width"] = kwargs.get("width", 800) - if static: - print_control = False - kwargs["draw_control"] = kwargs.get("draw_control", False) - kwargs["fullscreen_control"] = kwargs.get("fullscreen_control", False) - kwargs["layers_control"] = kwargs.get("layers_control", False) - kwargs["measure_control"] = kwargs.get("measure_control", False) - kwargs["zoom_control"] = kwargs.get("zoom_control", False) - kwargs["search_control"] = kwargs.get("search_control", False) + kwargs["layers"] = kwargs.get("layers", [self.get_named_tile_layer("OpenStreetMap")]) - self.px_height = kwargs["height"] - self.px_width = kwargs["width"] + if kwargs.get("deck_widgets") is None and default_widgets: + if static: + kwargs["deck_widgets"] = [ScaleWidget()] + else: + kwargs["deck_widgets"] = [FullscreenWidget(), ScaleWidget(), SaveImageWidget()] + + if static: + kwargs["controller"] = False super().__init__(*args, **kwargs) - if print_control: - self.add_print_control() + def add_layer(self, layer: Union[BaseLayer, List[BaseLayer]], zoom: bool = False): + """ + Adds a layer or list of layers to the map + Parameters + ---------- + layer : lonboard.BaseLayer or list[lonboard.BaseLayer] + zoom: bool + Whether to zoom the map to the new layer + """ + update = self.layers.copy() + if not isinstance(layer, list): + layer = [layer] + update.extend(layer) + self.layers = update + if zoom: + self.zoom_to_bounds(layer) - def add_gdf(self, data, *args, simplify_tolerance=None, **kwargs): + def add_widget(self, widget: BaseDeckWidget): """ - Wrapper for `geopandas.explore._explore`. + Adds a deck widget to the map + Parameters + ---------- + widget : lonboard.BaseDeckWidget or list[lonboard.BaseDeckWidget] """ + update = self.deck_widgets.copy() + update.append(widget) + self.deck_widgets = update + def add_gdf(self, data: Union[gpd.GeoDataFrame, gpd.GeoSeries], zoom: bool = True, **kwargs): + """ + Visualize a gdf on the map, results in one or more layers being added + Parameters + ---------- + data : lonboard.BaseDeckWidget or list[lonboard.BaseDeckWidget] + zoom : bool + Whether or not to zoom the map to the bounds of the data + kwargs: + Additional kwargs passed to lonboard.viz_layer + """ data = data.copy() data = data.to_crs(4326) data = data.loc[(~data.geometry.isna()) & (~data.geometry.is_empty)] - if simplify_tolerance is not None: - data = data.simplify(simplify_tolerance) - if isinstance(data, gpd.GeoDataFrame): for col in data: if pd.api.types.is_datetime64_any_dtype(data[col]): data[col] = data[col].astype("string") - kwargs["m"] = self - kwargs["tooltip"] = kwargs.get("tooltip", False) - gpd.explore._explore(data, *args, **kwargs) - - def add_legend(self, *args, **kwargs): - """ - Patched method for allowing legend hex colors to start with a "#". - """ - legend_dict = kwargs.get("legend_dict") - if legend_dict is not None: - kwargs["legend_dict"] = { - k: v[1:] if isinstance(v, str) and v.startswith("#") else v for k, v in legend_dict.items() - } + self.add_layer(viz_layer(data=data, **kwargs)) - return super().add_legend(*args, **kwargs) + if zoom: + self.zoom_to_bounds(data) - def add_north_arrow(self, imagePath="", position="topright", angle=0, scale=1.0): + def add_legend(self, **kwargs): """ + Adds a legend to the map Parameters ---------- - imagePath : str - Path to the image file for the north arrow. If not provided, default SVG image is uploaded. - position : str - Possible values are 'topleft', 'topright', 'bottomleft' or 'bottomright'. - angle : int - Angle for the north arrow to be rotated. - scale : float - Scale dimensions of north arrow. - + placement: str + One of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" + Where to place the widget within the map + title: str + A title displayed on the widget + labels: list[str] + A list of labels + colors: list[str] + A list of colors as hex values + style: dict + Additional style params """ + self.add_widget(LegendWidget(**kwargs)) - self.add_child( - NorthArrowElement( - html=f"""\ - - - """, # noqa - imagePath=imagePath, - position=position, - angle=angle, - scale=scale, - ) - ) - - def add_scale_bar(self, style="single", position="bottomleft", imperial=False, force=False): + def add_north_arrow(self, **kwargs): """ + Adds a north arrow to the map Parameters ---------- - style : str, optional - Possible values are 'single', 'double', 'empty' or 'comb'. Default is 'single'. - position : str, optional - Possible values are 'topleft', 'topright', 'bottomleft' or 'bottomright'. - imperial : bool, optional - If True, the scale bar uses miles and feet. Default is False. - force : bool, optional - If True, adds index=0 to the add_child() call, forcing the scale bar 'behind' Folium's default controls + placement: str, one of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" + Where to place the widget within the map + style: dict + Additional style params """ + self.add_widget(NorthArrowWidget(**kwargs)) - svg = ( - """01530km""" # noqa - if style == "double" - else """01530km""" # noqa - if style == "empty" - else """0151500km""" # noqa - if style == "comb" - else """01530km""" # noqa - ) - - self.add_child(ScaleElement(svg, position=position, imperial=imperial), index=0 if force else None) - - def add_title( - self, - title: str, - font_size: str = "32px", - font_style: str = "normal", - font_family: typing.Union[str, list] = "Helvetica", - font_color: typing.Union[str, tuple] = "rgba(0,0,0,1)", - position: dict = None, - background_color: typing.Union[str, tuple] = "#FFFFFF99", - outline: typing.Union[str, dict] = "0px solid rgba(0, 0, 0, 0)", - **kwargs, - ): + def add_scale_bar(self, **kwargs): """ + Adds a scale bar to the map Parameters ---------- - title : str - Text of title. - font_size : str - CSS font size that includes units. - font_style : str - font_family:str"Helvetica", - Font family selection; Could be one or more separated by spaces. - font_color : str - Text color (css color property); supports rgb, rgba and hex string formats. - position : dict|None - Dict object with top, left and bottom margin values for the title container position. - ex. { - "top": "10px", - "left": "25px", - "right": "0px", - "bottom": "0px" - } - All keys are optional in the dictionary (could be passed some of them as necessary). - Values could be passed as px or accepted css metric. Default None. - background_color : str - Box background color; supports rgb, rgba and hex string formats. Default '#FFFFFF99'. - outline : str - Element outline values (width style color_with_transparency). - Could be passed as a string with spaced separated values or a dict structure: - ex. { - "width": "1px", - "style": "solid", - "color": "#FFFFFF99" # or rgb/rgba tuple (0, 0, 0, 0) - } - kwargs - Additional style kwargs. Underscores in keys will be replaced with dashes + placement: str, one of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" + Where to place the widget within the map + use_imperial: bool + If true, show scale in miles/ft, rather than m/km + style: dict + Additional style params """ - position_styles = "" - if isinstance(position, dict): - VALID_POSITION_OPTIONS = ["top", "bottom", "left", "right"] - position_styles = " ".join([f"{k}:{v};" for k, v in position.items() if k in VALID_POSITION_OPTIONS]) - else: - position_styles = "left: 50%;" - if isinstance(font_family, str): - font = font_family - elif isinstance(font_family, list): - font = " ".join(font_family) - if isinstance(font_color, tuple): - if font_color.__len__() == 3: - fc = "rgb({},{},{})".format(font_color[0], font_color[1], font_color[2]) - elif font_color.__len__() == 4: - fc = "rgba({},{},{},{})".format(font_color[0], font_color[1], font_color[2], font_color[3]) - elif isinstance(font_color, str): - fc = font_color if font_color.startswith("#") else f"#{font_color}" - if isinstance(background_color, tuple): - if background_color.__len__() == 3: - bkg = "rgb({},{},{})".format(background_color[0], background_color[1], background_color[2]) - elif background_color.__len__() == 4: - bkg = "rgba({},{},{},{})".format( - background_color[0], background_color[1], background_color[2], background_color[3] - ) - elif isinstance(background_color, str): - bkg = background_color if background_color.startswith("#") else f"#{background_color}" - outline = ( - outline - if isinstance(outline, str) - else "{} {} {}".format(outline["width"], outline["style"], outline["color"]) - ) - title_html = f"""\ -
-

- { title } -

-
- """ - self.add_child(FloatElement(title_html, top=0, left=0, right=0)) - - def _repr_html_(self, **kwargs): - if kwargs.get("fill_parent", False): - original_width = self._parent.width - original_height = self._parent.height - - self._parent.width = "100%" - self._parent.height = "100%" - - html = ( - super() - ._repr_html_(**kwargs) - .replace(urllib.parse.quote("crs: L.CRS."), urllib.parse.quote("attributionControl: false, crs: L.CRS.")) - ) - - # this covers the (probably) rare case where someone in a Jupyter setting: - # creates a map, calls to_html(), continues changing the map, and then displays via _repr_html_() - if kwargs.get("fill_parent", False): - self._parent.width = original_width - self._parent.height = original_height - - return html + self.add_widget(ScaleWidget(**kwargs)) - def to_html(self, outfile, fill_parent=True, **kwargs): + def add_title(self, title: str, **kwargs): """ + Adds a title to the map Parameters ---------- - outfile : str, Pathlike - Output destination - + title: str + The map title + style: dict + Additional style params """ - with open(outfile, "w") as file: - file.write(self._repr_html_(fill_parent=fill_parent, **kwargs)) + kwargs["title"] = title + kwargs["placement"] = kwargs.get("placement", "fill") + kwargs["style"] = kwargs.get("style", {"position": "relative", "margin": "0 auto", "width": "35%"}) + + self.add_widget(TitleWidget(**kwargs)) - def to_png(self, outfile, sleep_time=5, **kwargs): + def add_save_image(self, **kwargs): """ + Adds a button to save the map as a png Parameters ---------- - outfile : str, Pathlike - Output destination - sleep_time : int, optional - Additional seconds to wait before taking screenshot. Should be increased if map tiles in the output haven't - fully loaded but can also be decreased in most cases. - + placement: str, one of "top-left", "top-right", "bottom-left", "bottom-right" or "fill" + Where to place the widget within the map + style: dict + Additional style params """ - tempfile = "tmp_to_png.html" - super().to_html(tempfile, **kwargs) - chrome_options = selenium.webdriver.chrome.options.Options() - chrome_options.add_argument("--headless") - chrome_options.add_argument("--no-sandbox") - chrome_options.add_argument("--disable-dev-shm-usage") - driver = selenium.webdriver.Chrome(options=chrome_options) - if self.px_width and self.px_height: - driver.set_window_size(width=self.px_width, height=self.px_height) - driver.get(f"file://{os.path.abspath(tempfile)}") - time.sleep(sleep_time) - driver.save_screenshot(outfile) - os.remove(tempfile) - - def add_ee_layer(self, ee_object, visualization_params, name) -> None: + self.add_widget(SaveImageWidget(**kwargs)) + + def add_ee_layer( + self, + ee_object: Union[ee.Image, ee.ImageCollection, ee.Geometry, ee.FeatureCollection], + visualization_params: Dict, + **kwargs + ): """ - Method for displaying Earth Engine image tiles. + Adds a provided Earth Engine object to the map. + If an EE.Image/EE.ImageCollection or EE.FeatureCollection is provided, + this results in a BitmapTileLayer being added + + For EE.Geometry objects, a list of ScatterplotLayer,PathLayer and PolygonLayer will be added + based on the geometry itself (defers to lonboard.viz) Parameters ---------- - ee_object - visualization_params - name + ee_object: ee.Image, ee.ImageCollection, ee.Geometry, ee.FeatureCollection] + The ee object to represent as a layer + visualization_params: dict + Visualization params passed to EarthEngine + kwargs + Additional params passed to either lonboard.BitmapTileLayer or lonboard.viz Returns ------- None - """ + if isinstance(ee_object, ee.image.Image): + map_id_dict = ee.Image(ee_object).getMapId(visualization_params) + ee_layer = BitmapTileLayer(data=map_id_dict["tile_fetcher"].url_format, **kwargs) - try: - if isinstance(ee_object, ee.image.Image): - map_id_dict = ee.Image(ee_object).getMapId(visualization_params) - folium.raster_layers.TileLayer( - tiles=map_id_dict["tile_fetcher"].url_format, - attr="Google Earth Engine", - name=name, - overlay=True, - control=True, - ).add_to(self) - - elif isinstance(ee_object, ee.imagecollection.ImageCollection): - ee_object_new = ee_object.mosaic() - map_id_dict = ee.Image(ee_object_new).getMapId(visualization_params) - folium.raster_layers.TileLayer( - tiles=map_id_dict["tile_fetcher"].url_format, - attr="Google Earth Engine", - name=name, - overlay=True, - control=True, - ).add_to(self) - - elif isinstance(ee_object, ee.geometry.Geometry): - folium.GeoJson(data=ee_object.getInfo(), name=name, overlay=True, control=True).add_to(self) - - elif isinstance(ee_object, ee.featurecollection.FeatureCollection): - ee_object_new = ee.Image().paint(ee_object, 0, 2) - map_id_dict = ee.Image(ee_object_new).getMapId(visualization_params) - folium.raster_layers.TileLayer( - tiles=map_id_dict["tile_fetcher"].url_format, - attr="Google Earth Engine", - name=name, - overlay=True, - control=True, - ).add_to(self) - - except Exception as exc: - print(f"{exc}. Could not display {name}") - - def zoom_to_bounds(self, bounds): - """Zooms to a bounding box in the form of [minx, miny, maxx, maxy]. + elif isinstance(ee_object, ee.imagecollection.ImageCollection): + ee_object_new = ee_object.mosaic() + map_id_dict = ee.Image(ee_object_new).getMapId(visualization_params) + ee_layer = BitmapTileLayer(data=map_id_dict["tile_fetcher"].url_format, **kwargs) - Parameters - ---------- - bounds : [x1, y1, x2, y2] - Map extent in WGS 84 - """ + elif isinstance(ee_object, ee.geometry.Geometry): + geojson = ee_object.toGeoJSON() + gdf = gpd.read_file(json.dumps(geojson), driver="GeoJSON") + ee_layer = viz_layer(data=gdf, **kwargs) - assert -180 < bounds[0] <= bounds[2] < 180 - assert -90 < bounds[1] <= bounds[3] < 90 + elif isinstance(ee_object, ee.featurecollection.FeatureCollection): + ee_object_new = ee.Image().paint(ee_object, 0, 2) + map_id_dict = ee.Image(ee_object_new).getMapId(visualization_params) + ee_layer = BitmapTileLayer(data=map_id_dict["tile_fetcher"].url_format, **kwargs) - self.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]]) + self.add_layer(ee_layer) - def zoom_to_gdf(self, gs): + def zoom_to_bounds(self, feat: Union[BaseLayer, List[BaseLayer], gpd.GeoDataFrame]): """ + Zooms the map to the bounds of a dataframe or layer. + Parameters ---------- - gs : gpd.GeoSeries or gpd.GeoDataFrame - Geometry to adjust map bounds to. CRS will be converted to WGS 84. - + feat : BaseLayer, List[lonboard.BaseLayer], gpd.GeoDataFrame + The feature to zoom to """ + if feat is None: + view_state = compute_view(self.layers) + elif isinstance(feat, gpd.GeoDataFrame): + bounds = feat.to_crs(4326).total_bounds + bbox = Bbox(minx=bounds[0], miny=bounds[1], maxx=bounds[2], maxy=bounds[3]) + + centerLon = (bounds[0] + bounds[2]) / 2 + centerLat = (bounds[1] + bounds[3]) / 2 + + view_state = { + "longitude": centerLon, + "latitude": centerLat, + "zoom": bbox_to_zoom_level(bbox), + "pitch": 0, + "bearing": 0, + } + else: + view_state = compute_view(feat) - self.zoom_to_bounds(gs.geometry.to_crs(4326).total_bounds) + self.set_view_state(**view_state) - def add_local_geotiff(self, path, zoom=False, cmap=None, colorbar=True): + def add_geotiff( + self, + path: str, + zoom: bool = False, + cmap: Union[str, mpl.colors.Colormap] = None, + opacity: float = 0.7, + ): """ - Displays a local GeoTIFF. + Adds a local geotiff to the map + Note that since deck.gl tiff support is limited, this extracts the CRS/Bounds from the tiff + and converts the image data in-memory to PNG Parameters ---------- - path : str, Pathlike - Path to local GeoTIFF + path : str + The path to the local tiff zoom : bool - Zoom to displayed GeoTIFF - cmap : `matplotlib.colors.Colormap` or str or None - Matplotlib colormap to apply to raster - colorbar : bool - Whether to add colorbar for provided `cmap`. Does nothing if `cmap` is not provided. + Whether to zoom the map to the bounds of the tiff + cmap: str or matplotlib.colors.Colormap + The colormap to apply to the raster + opacity: float + The opacity of the overlay """ - with rasterio.open(path) as src: transform, width, height = rasterio.warp.calculate_default_transform( src.crs, "EPSG:4326", src.width, src.height, *src.bounds ) - kwargs = src.meta.copy() - kwargs.update({"crs": "EPSG:4326", "transform": transform, "width": width, "height": height}) + rio_kwargs = src.meta.copy() + rio_kwargs.update({"crs": "EPSG:4326", "transform": transform, "width": width, "height": height}) + + # new + bounds = rasterio.warp.transform_bounds(src.crs, "EPSG:4326", *src.bounds) if cmap is None: im = [rasterio.band(src, i + 1) for i in range(src.count)] else: - cmap = mpl.cm.get_cmap(cmap) - kwargs["count"] = 4 + cmap = mpl.colormaps[cmap] + rio_kwargs["count"] = 4 im = rasterio.band(src, 1)[0].read()[0] im_min, im_max = np.nanmin(im), np.nanmax(im) im = np.rollaxis(cmap((im - im_min) / (im_max - im_min), bytes=True), -1) - if colorbar: - if isinstance(im_min, np.integer) and im_max - im_min < 256: - self.add_child( - StepColormap( - [mpl.colors.rgb2hex(color) for color in cmap(np.linspace(0, 1, 1 + im_max - im_min))], - index=np.arange(1 + im_max - im_min), - vmin=im_min, - vmax=im_max + 1, - ) - ) - else: - self.add_child( - StepColormap( - [mpl.colors.rgb2hex(color) for color in cmap(np.linspace(0, 1, 256))], - vmin=im_min, - vmax=im_max, - ) - ) + # TODO Handle Colorbar with rasterio.io.MemoryFile() as memfile: - with memfile.open(**kwargs) as dst: - for i in range(kwargs["count"]): + with memfile.open(**rio_kwargs) as dst: + for i in range(rio_kwargs["count"]): rasterio.warp.reproject( source=im[i], destination=rasterio.band(dst, i + 1), @@ -460,15 +339,30 @@ def add_local_geotiff(self, path, zoom=False, cmap=None, colorbar=True): dst_crs="EPSG:4326", resampling=rasterio.warp.Resampling.nearest, ) - - url = "data:image/tiff;base64," + base64.b64encode(memfile.read()).decode("utf-8") - - self.add_child(GeoTIFFElement(url, zoom)) - - def add_print_control(self): - self.add_child(PrintControl()) - - def add_pil_image(self, image, bounds, name=None, zoom=True, opacity=1): + height = dst.height + width = dst.width + + data = dst.read( + out_dtype=rasterio.uint8, + out_shape=(rio_kwargs["count"], int(height), int(width)), + resampling=rasterio.enums.Resampling.bilinear, + ) + + with rasterio.io.MemoryFile() as outfile: + with outfile.open( + driver="PNG", + height=data.shape[1], + width=data.shape[2], + count=rio_kwargs["count"], + dtype=data.dtype, + ) as mempng: + mempng.write(data) + url = "data:image/png;base64," + base64.b64encode(outfile.read()).decode("utf-8") + + layer = BitmapLayer(image=url, bounds=bounds, opacity=opacity) + self.add_layer(layer, zoom=zoom) + + def add_pil_image(self, image, bounds, zoom=True, opacity=1): """ Overlays a PIL.Image onto the Ecomap @@ -478,302 +372,59 @@ def add_pil_image(self, image, bounds, name=None, zoom=True, opacity=1): The image to be overlaid bounds: tuple Tuple containing the EPSG:4326 (minx, miny, maxx, maxy) values bounding the given image - name : string, optional - The name of the image layer to be displayed in Folium layer control zoom : bool, optional Zoom to the generated image opacity : float, optional Sets opacity of overlaid image """ - folium.raster_layers.ImageOverlay( - image=image, - bounds=[[bounds[1], bounds[0]], [bounds[3], bounds[2]]], - opacity=opacity, - mercator_project=True, - name=name, - ).add_to(self) - - if zoom: - self.zoom_to_bounds(bounds) - - -class ControlElement(MacroElement): - """ - Class to wrap arbitrary HTML as Leaflet Control. - - Parameters - ---------- - html : str - HTML to render an element from. - position : str - Possible values are 'topleft', 'topright', 'bottomleft' or 'bottomright'. - - """ - - _template = Template( - """ - {% macro script(this, kwargs) %} - var {{ this.get_name() }} = L.Control.extend({ - onAdd: function(map) { - var template = document.createElement('template'); - template.innerHTML = `{{ this.html }}`.trim(); - return template.content.firstChild; - } - }); - (new {{ this.get_name() }}({{ this.options|tojson }})).addTo({{this._parent.get_name()}}); - - {% endmacro %} - """ - ) - - def __init__(self, html, position="bottomright"): - super().__init__() - self.html = html - self.options = folium.utilities.parse_options( - position=position, - ) - - -class NorthArrowElement(ControlElement): - """ - Class to wrap arbitrary HTML or PNG as Leaflet Control. - - Parameters - ---------- - html : str - HTML to render an element from. - imagePath: str - Path to the image file for the north arrow. If not provided, default SVG image is uploaded. - position : str - Possible values are 'topleft', 'topright', 'bottomleft' or 'bottomright'. - angle : int - Angle for the north arrow to be rotated. - scale : float - Scale dimensions of north arrow. - - """ - - _template = Template( - """ - {% macro script(this, kwargs) %} - var {{ this.get_name() }} = L.Control.extend({ - onAdd: function(map) { - var control; - if (this.options.imagePath){ - var img = document.createElement('img'); - img.src=this.options.imagePath; - img.style.width = (this.options.scale * 100) + 'px'; - img.style.height = (this.options.scale * 100) + 'px'; - control=img; - } else { - var template = document.createElement('template'); - template.innerHTML = `{{ this.html }}`.trim(); - control = template.content.firstChild - } - - control.style.transform = 'rotate({{ this.angle }}deg)'; - return control; - } - }); - (new {{ this.get_name() }}({{ this.options|tojson }})).addTo({{this._parent.get_name()}}); - - {% endmacro %} - """ - ) - - def __init__(self, html, imagePath="", position="bottomright", angle="", scale=""): - super().__init__(html=html, position=position) - self.angle = angle - self.options = folium.utilities.parse_options(imagePath=imagePath, scale=scale, position=position) - - -class ScaleElement(MacroElement): - """ - Class to wrap arbitrary HTML as Leaflet Control. - - Parameters - ---------- - html : str - HTML to render an element from. - position : str - Possible values are 'topleft', 'topright', 'bottomleft' or 'bottomright'. - - """ - - _template = Template( - """ - {% macro script(this, kwargs) %} - - var {{ this.get_name() }} = L.Control.Scale.extend({ - onAdd: function(map) { - - var container = document.createElement('div'); - container.classList.add("leaflet-control-scale"); - container.innerHTML = `{{ this.html }}`.trim(); - - this._scale = container.firstChild; - - map.on('move', this._update, this); - map.whenReady(this._update, this); - - return container; + data = BytesIO() + image.save(data, "PNG") + + url = "data:image/png;base64," + base64.b64encode(data.getvalue()).decode("utf-8") + layer = BitmapLayer(image=url, bounds=bounds.tolist(), opacity=opacity) + self.add_layer(layer, zoom=zoom) + + @staticmethod + def get_named_tile_layer(layer: str) -> BitmapTileLayer: + # From Leafmap + # https://github.com/opengeos/leafmap/blob/master/leafmap/basemaps.py + xyz_tiles = { + "OpenStreetMap": { + "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "attribution": "OpenStreetMap", + "name": "OpenStreetMap", + "max_requests": -1, }, - - _updateImperial(maxMeters) { - const maxFeet = maxMeters * 3.2808399; - let maxMiles, miles, feet; - - if (maxFeet > 5280) { - maxMiles = maxFeet / 5280; - miles = this._getRoundNum(maxMiles); - this._updateScale(this._scale, miles, "mi", miles / maxMiles); - - } else { - feet = this._getRoundNum(maxFeet); - this._updateScale(this._scale, feet, "ft", feet / maxFeet); - } + "ROADMAP": { + "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}", # noqa + "attribution": "Esri", + "name": "Esri.WorldStreetMap", }, - - _updateMetric(maxMeters) { - const meters = this._getRoundNum(maxMeters), - label = meters < 1000 ? `${meters} m` : `${meters / 1000} km`; - - value = meters < 1000 ? meters : meters / 1000; - unit = meters < 1000 ? `m` : `km`; - - this._updateScale(this._scale, value, unit, meters / maxMeters); + "SATELLITE": { + "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + "attribution": "Esri", + "name": "Esri.WorldImagery", }, - - _updateScale(scale, value, unit, ratio) { - scale.style.width = `${Math.round(this.options.maxWidth * ratio * (4/3))}px`; - - scale.getElementById("scale").textContent = value; - scale.getElementById("half_scale").textContent = value / 2; - scale.getElementById("unit").textContent = unit; - + "TERRAIN": { + "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", + "attribution": "Esri", + "name": "Esri.WorldTopoMap", }, - - onRemove(map) { - map.off('move', this._update, this); - } - - }); - (new {{ this.get_name() }}({{ this.options|tojson }})).addTo({{this._parent.get_name()}}); - - {% endmacro %} - """ - ) - - def __init__(self, html, position="bottomright", imperial=False): - super().__init__() - self.html = html - self.options = folium.utilities.parse_options( - position=position, maxWidth=300, imperial=imperial, metric=not imperial + "HYBRID": { + "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + "attribution": "Esri", + "name": "Esri.WorldImagery", + }, + } + + layer = xyz_tiles.get(layer) + if not layer: + raise ValueError("string layer name must be in {}".format(", ".join(xyz_tiles.keys()))) + return BitmapTileLayer( + data=layer.get("url"), + tile_size=layer.get("tile_size", 128), + max_zoom=layer.get("max_zoom", None), + min_zoom=layer.get("min_zoom", None), + max_requests=layer.get("max_requests", None), ) - - -class FloatElement(MacroElement): - """ - Class to wrap arbitrary HTML as a floating Element. - - Parameters - ---------- - html : str - HTML to render an element from. - left, right, top, bottom : str - Distance between edge of map and nearest edge of element. Two should be provided. Style can also be specified - in html. - - """ - - _template = Template( - """ - {% macro header(this,kwargs) %} - - {% endmacro %} - - {% macro html(this,kwargs) %} -
- {{ this.html }} -
- {% endmacro %} - """ - ) - - def __init__(self, html, left="", right="", top="", bottom=""): - super().__init__() - self.html = html - self.left = left - self.right = right - self.top = top - self.bottom = bottom - - -class GeoTIFFElement(MacroElement): - """ - Class to display a GeoTIFF. - - Parameters - ---------- - url : str - URL of GeoTIFF - zoom : bool - Zoom to displayed GeoTIFF - """ - - _template = Template( - """ - {% macro html(this, kwargs) %} - - - {% endmacro %} - - {% macro script(this, kwargs) %} - fetch(`{{ this.url }}`) - .then(response => response.arrayBuffer()) - .then(arrayBuffer => { - parseGeoraster(arrayBuffer).then(georaster => { - var layer = new GeoRasterLayer({ - georaster: georaster, - opacity: 0.7 - }); - layer.addTo({{this._parent.get_name()}}); - - if ("True" == `{{ this.zoom }}`) { - ({{ this._parent.get_name() }}).fitBounds(layer.getBounds()); - } - - }); - }); - {% endmacro %} - """ # noqa - ) - - def __init__(self, url, zoom=False): - super().__init__() - self.url = url - self.zoom = zoom - - -class PrintControl(MacroElement): - _template = Template( - """ - {% macro header(this, kwargs) %} - - - {% endmacro %} - {% macro script(this, kwargs) %} - L.control.BigImage().addTo(({{ this._parent.get_name() }})); - {% endmacro %} - """ # noqa - ) diff --git a/tests/test_ecomap.py b/tests/test_ecomap.py index c2da71c1..731d7e8a 100644 --- a/tests/test_ecomap.py +++ b/tests/test_ecomap.py @@ -1,87 +1,71 @@ -from ecoscope.mapping import ( - EcoMap, - FloatElement, - NorthArrowElement, - ScaleElement, - GeoTIFFElement, - PrintControl, -) -import os import ee -import geopandas +import geopandas as gpd import pytest -from branca.element import MacroElement -from branca.colormap import StepColormap -from folium.raster_layers import TileLayer, ImageOverlay -from folium.map import FitBounds +from ecoscope.mapping import EcoMap from ecoscope.analysis.geospatial import datashade_gdf -from bs4 import BeautifulSoup +from lonboard._layer import BitmapLayer, BitmapTileLayer, PolygonLayer +from lonboard._deck_widget import ( + NorthArrowWidget, + ScaleWidget, + TitleWidget, + SaveImageWidget, + FullscreenWidget, +) def test_ecomap_base(): m = EcoMap() - assert len(m._children) == 7 - - -def test_repr_html(): - m = EcoMap(width=800, height=600) - soup = BeautifulSoup(m._repr_html_(), "html.parser") - assert soup.iframe.get("width") == "800" - assert soup.iframe.get("height") == "600" - - soup = BeautifulSoup(m._repr_html_(fill_parent=True), "html.parser") - assert soup.iframe.get("width") == "100%" - assert soup.iframe.get("height") == "100%" - - assert m._parent.width == 800 and m._parent.height == 600 + assert len(m.deck_widgets) == 3 + assert len(m.layers) == 1 + assert isinstance(m.layers[0], BitmapTileLayer) + assert isinstance(m.deck_widgets[0], FullscreenWidget) + assert isinstance(m.deck_widgets[1], ScaleWidget) + assert isinstance(m.deck_widgets[2], SaveImageWidget) def test_static_map(): - m = EcoMap(width=800, height=600, static=True) - assert len(m._children) == 2 + m = EcoMap(static=True) - -def test_to_png(): - output_path = "tests/outputs/ecomap.png" - m = EcoMap(width=800, height=600) - m.to_png(output_path) - assert os.path.exists(output_path) + assert m.controller is False + assert len(m.deck_widgets) == 1 + assert len(m.layers) == 1 + assert isinstance(m.layers[0], BitmapTileLayer) + assert isinstance(m.deck_widgets[0], ScaleWidget) def test_add_legend(): - m = EcoMap() - # Test that we can assign hex colors with or without # - m.add_legend(legend_dict={"Black_NoHash": "000000", "White_Hash": "#FFFFFF"}) - assert len(m.get_root()._children) == 2 - assert isinstance(list(m.get_root()._children.values())[1], MacroElement) - html = m._repr_html_() - assert "Black_NoHash" in html - assert "White_Hash" in html + m = EcoMap(default_widgets=False) + m.add_legend(labels=["Black", "White"], colors=["#000000", "#FFFFFF"]) + assert len(m.deck_widgets) == 1 def test_add_north_arrow(): m = EcoMap() m.add_north_arrow() - assert len(m._children) == 8 - assert isinstance(list(m._children.values())[7], NorthArrowElement) - assert "<svg" in m._repr_html_() + assert len(m.deck_widgets) == 4 + assert isinstance(m.deck_widgets[3], NorthArrowWidget) def test_add_scale_bar(): m = EcoMap() m.add_scale_bar() - assert len(m._children) == 8 - assert isinstance(list(m._children.values())[7], ScaleElement) - assert "<svg" in m._repr_html_() + assert len(m.deck_widgets) == 4 + assert isinstance(m.deck_widgets[3], ScaleWidget) def test_add_title(): m = EcoMap() m.add_title("THIS IS A TEST TITLE") - assert len(m._children) == 8 - assert isinstance(list(m._children.values())[7], FloatElement) - assert "THIS IS A TEST TITLE" in m._repr_html_() + assert len(m.deck_widgets) == 4 + assert isinstance(m.deck_widgets[3], TitleWidget) + + +def test_add_save_image(): + m = EcoMap() + m.add_save_image() + assert len(m.deck_widgets) == 4 + assert isinstance(m.deck_widgets[3], SaveImageWidget) @pytest.mark.skipif(not pytest.earthengine, reason="No connection to EarthEngine.") @@ -89,10 +73,9 @@ def test_add_ee_layer_image(): m = EcoMap() vis_params = {"min": 0, "max": 4000, "opacity": 0.5, "palette": ["006633", "E5FFCC", "662A00", "D8D8D8", "F5F5F5"]} ee_object = ee.Image("USGS/SRTMGL1_003") - m.add_ee_layer(ee_object, vis_params, "DEM") - assert len(m._children) == 8 - assert isinstance(list(m._children.values())[7], TileLayer) - assert "earthengine.googleapis.com" in m._repr_html_() + m.add_ee_layer(ee_object, vis_params) + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapTileLayer) @pytest.mark.skipif(not pytest.earthengine, reason="No connection to EarthEngine.") @@ -100,29 +83,28 @@ def test_add_ee_layer_image_collection(): m = EcoMap() vis_params = {"min": 0, "max": 4000, "opacity": 0.5} ee_object = ee.ImageCollection("MODIS/006/MCD43C3") - m.add_ee_layer(ee_object, vis_params, "DEM") - assert len(m._children) == 8 - assert isinstance(list(m._children.values())[7], TileLayer) - assert "earthengine.googleapis.com" in m._repr_html_() + m.add_ee_layer(ee_object, vis_params) + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapTileLayer) -@pytest.mark.skipif(not pytest.earthengine, reason="No connection to EarthEngine.") +@pytest.mark.skipif(not pytest.earthengine, reason="No onnection to EarthEngine.") def test_add_ee_layer_feature_collection(): m = EcoMap() vis_params = {"min": 0, "max": 4000, "opacity": 0.5, "palette": ["006633", "E5FFCC", "662A00", "D8D8D8", "F5F5F5"]} ee_object = ee.FeatureCollection("LARSE/GEDI/GEDI02_A_002/GEDI02_A_2021244154857_O15413_04_T05622_02_003_02_V002") - m.add_ee_layer(ee_object, vis_params, "DEM") - assert len(m._children) == 8 - assert isinstance(list(m._children.values())[7], TileLayer) - assert "earthengine.googleapis.com" in m._repr_html_() + m.add_ee_layer(ee_object, vis_params) + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapTileLayer) -def test_zoom_to_bounds(): +@pytest.mark.skipif(not pytest.earthengine, reason="No connection to EarthEngine.") +def test_add_ee_layer_geometry(): m = EcoMap() - m.zoom_to_bounds((34.683838, -3.173425, 38.869629, 0.109863)) - assert len(m._children) == 8 - assert isinstance(list(m._children.values())[7], FitBounds) - assert "[[-3.173425, 34.683838], [0.109863, 38.869629]]" in m._repr_html_() + rectangle = ee.Geometry.Rectangle([-40, -20, 40, 20]) + m.add_ee_layer(rectangle, None) + assert len(m.layers) == 2 + assert isinstance(m.layers[1], PolygonLayer) def test_zoom_to_gdf(): @@ -131,41 +113,28 @@ def test_zoom_to_gdf(): y1 = -3.173425 x2 = 38.869629 y2 = 0.109863 - gs = geopandas.GeoSeries.from_wkt( + gs = gpd.GeoSeries.from_wkt( [f"POINT ({x1} {y1})", f"POINT ({x2} {y1})", f"POINT ({x1} {y2})", f"POINT ({x2} {y2})"] ) gs = gs.set_crs("EPSG:4326") - m.zoom_to_gdf(gs) - assert len(m._children) == 8 - assert isinstance(list(m._children.values())[7], FitBounds) - assert "[[-3.173425, 34.683838], [0.109863, 38.869629]]" in m._repr_html_() - + m.zoom_to_bounds(feat=gpd.GeoDataFrame(geometry=gs)) -def test_add_local_geotiff(): - m = EcoMap() - m.add_local_geotiff("tests/sample_data/raster/uint8.tif", cmap=None) - assert len(m._children) == 8 - assert isinstance(list(m._children.values())[7], GeoTIFFElement) - assert "new GeoRasterLayer" in m._repr_html_() + assert m.view_state.longitude == (x1 + x2) / 2 + assert m.view_state.latitude == (y1 + y2) / 2 -def test_add_local_geotiff_with_cmap(): +def test_add_geotiff(): m = EcoMap() - m.add_local_geotiff("tests/sample_data/raster/uint8.tif", cmap="jet") - assert len(m._children) == 9 - assert isinstance(list(m._children.values())[7], StepColormap) - assert isinstance(list(m._children.values())[8], GeoTIFFElement) - html = m._repr_html_() - assert "new GeoRasterLayer" in html - assert "d3.scale" in html + m.add_geotiff("tests/sample_data/raster/uint8.tif", cmap=None) + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapLayer) -def test_add_print_control(): +def test_add_geotiff_with_cmap(): m = EcoMap() - m.add_print_control() - assert len(m._children) == 8 - assert isinstance(list(m._children.values())[7], PrintControl) - assert "L.control.BigImage()" in m._repr_html_() + m.add_geotiff("tests/sample_data/raster/uint8.tif", cmap="jet") + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapLayer) @pytest.mark.parametrize( @@ -177,20 +146,19 @@ def test_add_print_control(): ) def test_add_datashader_gdf(file, geom_type): m = EcoMap() - gdf = geopandas.GeoDataFrame.from_file(file) + gdf = gpd.GeoDataFrame.from_file(file) img, bounds = datashade_gdf(gdf, geom_type) m.add_pil_image(img, bounds, zoom=False) - assert len(m._children) == 8 - assert isinstance(list(m._children.values())[7], ImageOverlay) - assert "L.imageOverlay(" in m._repr_html_() + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapLayer) def test_add_datashader_gdf_with_zoom(): m = EcoMap() - gdf = geopandas.GeoDataFrame.from_file("tests/sample_data/vector/maec_4zones_UTM36S.gpkg") + gdf = gpd.GeoDataFrame.from_file("tests/sample_data/vector/maec_4zones_UTM36S.gpkg") img, bounds = datashade_gdf(gdf, "polygon") m.add_pil_image(img, bounds) - assert len(m._children) == 9 - assert isinstance(list(m._children.values())[7], ImageOverlay) - assert isinstance(list(m._children.values())[8], FitBounds) - assert "L.imageOverlay(" in m._repr_html_() + assert len(m.layers) == 2 + assert isinstance(m.layers[1], BitmapLayer) + assert m.view_state.longitude == (bounds[0] + bounds[2]) / 2 + assert m.view_state.latitude == (bounds[1] + bounds[3]) / 2 diff --git a/tests/test_ecomap2.py b/tests/test_ecomap2.py deleted file mode 100644 index 06eb4360..00000000 --- a/tests/test_ecomap2.py +++ /dev/null @@ -1,164 +0,0 @@ -import ee -import geopandas as gpd -import pytest -from ecoscope.mapping.lonboard_map import EcoMap2 -from ecoscope.analysis.geospatial import datashade_gdf -from lonboard._layer import BitmapLayer, BitmapTileLayer, PolygonLayer -from lonboard._deck_widget import ( - NorthArrowWidget, - ScaleWidget, - TitleWidget, - SaveImageWidget, - FullscreenWidget, -) - - -def test_ecomap_base(): - m = EcoMap2() - - assert len(m.deck_widgets) == 3 - assert len(m.layers) == 1 - assert isinstance(m.layers[0], BitmapTileLayer) - assert isinstance(m.deck_widgets[0], FullscreenWidget) - assert isinstance(m.deck_widgets[1], ScaleWidget) - assert isinstance(m.deck_widgets[2], SaveImageWidget) - - -def test_static_map(): - m = EcoMap2(static=True) - - assert m.controller is False - assert len(m.deck_widgets) == 1 - assert len(m.layers) == 1 - assert isinstance(m.layers[0], BitmapTileLayer) - assert isinstance(m.deck_widgets[0], ScaleWidget) - - -def test_add_legend(): - m = EcoMap2(default_widgets=False) - m.add_legend(labels=["Black", "White"], colors=["#000000", "#FFFFFF"]) - assert len(m.deck_widgets) == 1 - - -def test_add_north_arrow(): - m = EcoMap2() - m.add_north_arrow() - assert len(m.deck_widgets) == 4 - assert isinstance(m.deck_widgets[3], NorthArrowWidget) - - -def test_add_scale_bar(): - m = EcoMap2() - m.add_scale_bar() - assert len(m.deck_widgets) == 4 - assert isinstance(m.deck_widgets[3], ScaleWidget) - - -def test_add_title(): - m = EcoMap2() - m.add_title("THIS IS A TEST TITLE") - assert len(m.deck_widgets) == 4 - assert isinstance(m.deck_widgets[3], TitleWidget) - - -def test_add_save_image(): - m = EcoMap2() - m.add_save_image() - assert len(m.deck_widgets) == 4 - assert isinstance(m.deck_widgets[3], SaveImageWidget) - - -@pytest.mark.skipif(not pytest.earthengine, reason="No connection to EarthEngine.") -def test_add_ee_layer_image(): - m = EcoMap2() - vis_params = {"min": 0, "max": 4000, "opacity": 0.5, "palette": ["006633", "E5FFCC", "662A00", "D8D8D8", "F5F5F5"]} - ee_object = ee.Image("USGS/SRTMGL1_003") - m.add_ee_layer(ee_object, vis_params) - assert len(m.layers) == 2 - assert isinstance(m.layers[1], BitmapTileLayer) - - -@pytest.mark.skipif(not pytest.earthengine, reason="No connection to EarthEngine.") -def test_add_ee_layer_image_collection(): - m = EcoMap2() - vis_params = {"min": 0, "max": 4000, "opacity": 0.5} - ee_object = ee.ImageCollection("MODIS/006/MCD43C3") - m.add_ee_layer(ee_object, vis_params) - assert len(m.layers) == 2 - assert isinstance(m.layers[1], BitmapTileLayer) - - -@pytest.mark.skipif(not pytest.earthengine, reason="No onnection to EarthEngine.") -def test_add_ee_layer_feature_collection(): - m = EcoMap2() - vis_params = {"min": 0, "max": 4000, "opacity": 0.5, "palette": ["006633", "E5FFCC", "662A00", "D8D8D8", "F5F5F5"]} - ee_object = ee.FeatureCollection("LARSE/GEDI/GEDI02_A_002/GEDI02_A_2021244154857_O15413_04_T05622_02_003_02_V002") - m.add_ee_layer(ee_object, vis_params) - assert len(m.layers) == 2 - assert isinstance(m.layers[1], BitmapTileLayer) - - -@pytest.mark.skipif(not pytest.earthengine, reason="No connection to EarthEngine.") -def test_add_ee_layer_geometry(): - m = EcoMap2() - rectangle = ee.Geometry.Rectangle([-40, -20, 40, 20]) - m.add_ee_layer(rectangle, None) - assert len(m.layers) == 2 - assert isinstance(m.layers[1], PolygonLayer) - - -def test_zoom_to_gdf(): - m = EcoMap2() - x1 = 34.683838 - y1 = -3.173425 - x2 = 38.869629 - y2 = 0.109863 - gs = gpd.GeoSeries.from_wkt( - [f"POINT ({x1} {y1})", f"POINT ({x2} {y1})", f"POINT ({x1} {y2})", f"POINT ({x2} {y2})"] - ) - gs = gs.set_crs("EPSG:4326") - m.zoom_to_bounds(feat=gpd.GeoDataFrame(geometry=gs)) - - assert m.view_state.longitude == (x1 + x2) / 2 - assert m.view_state.latitude == (y1 + y2) / 2 - - -def test_add_geotiff(): - m = EcoMap2() - m.add_geotiff("tests/sample_data/raster/uint8.tif", cmap=None) - assert len(m.layers) == 2 - assert isinstance(m.layers[1], BitmapLayer) - - -def test_add_geotiff_with_cmap(): - m = EcoMap2() - m.add_geotiff("tests/sample_data/raster/uint8.tif", cmap="jet") - assert len(m.layers) == 2 - assert isinstance(m.layers[1], BitmapLayer) - - -@pytest.mark.parametrize( - "file, geom_type", - [ - ("tests/sample_data/vector/maec_4zones_UTM36S.gpkg", "polygon"), - ("tests/sample_data/vector/observations.geojson", "point"), - ], -) -def test_add_datashader_gdf(file, geom_type): - m = EcoMap2() - gdf = gpd.GeoDataFrame.from_file(file) - img, bounds = datashade_gdf(gdf, geom_type) - m.add_pil_image(img, bounds, zoom=False) - assert len(m.layers) == 2 - assert isinstance(m.layers[1], BitmapLayer) - - -def test_add_datashader_gdf_with_zoom(): - m = EcoMap2() - gdf = gpd.GeoDataFrame.from_file("tests/sample_data/vector/maec_4zones_UTM36S.gpkg") - img, bounds = datashade_gdf(gdf, "polygon") - m.add_pil_image(img, bounds) - assert len(m.layers) == 2 - assert isinstance(m.layers[1], BitmapLayer) - assert m.view_state.longitude == (bounds[0] + bounds[2]) / 2 - assert m.view_state.latitude == (bounds[1] + bounds[3]) / 2 From c26d026a1d4c210bcf603415ce2641ecc5ddaf41 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Tue, 25 Jun 2024 22:35:05 +1000 Subject: [PATCH 19/28] remove contrib/foliummap --- MANIFEST.in | 1 - ecoscope/contrib/basemaps.py | 325 ---- ecoscope/contrib/foliumap.py | 2937 ---------------------------------- ecoscope/contrib/legend.txt | 102 -- 4 files changed, 3365 deletions(-) delete mode 100644 ecoscope/contrib/basemaps.py delete mode 100644 ecoscope/contrib/foliumap.py delete mode 100644 ecoscope/contrib/legend.txt diff --git a/MANIFEST.in b/MANIFEST.in index 466b4cef..51c746a4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,3 @@ exclude *.txt exclude MANIFEST.in recursive-include *.pxi *.pxd *.pyx *.so -include ecoscope/contrib/legend.txt \ No newline at end of file diff --git a/ecoscope/contrib/basemaps.py b/ecoscope/contrib/basemaps.py deleted file mode 100644 index 2193ef5b..00000000 --- a/ecoscope/contrib/basemaps.py +++ /dev/null @@ -1,325 +0,0 @@ -""" -MIT License - -Copyright (c) 2021, Qiusheng Wu - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -""" - -__version__ = "be6ccdfa5450b775724547b6ef50f213c820be85" - -"""Module for basemaps. - -More WMS basemaps can be found at the following websites: - -1. USGS National Map: https://viewer.nationalmap.gov/services - -2. MRLC NLCD Land Cover data: https://www.mrlc.gov/data-services-page - -3. FWS NWI Wetlands data: https://www.fws.gov/wetlands/Data/Web-Map-Services.html - -""" -import collections -import folium -import xyzservices.providers as xyz - -# from box import Box - -# Custom XYZ tile services. -xyz_tiles = { - "OpenStreetMap": { - "url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - "attribution": "OpenStreetMap", - "name": "OpenStreetMap", - }, - "ROADMAP": { - "url": "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}", - "attribution": "Google", - "name": "Google Maps", - }, - "SATELLITE": { - "url": "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}", - "attribution": "Google", - "name": "Google Satellite", - }, - "TERRAIN": { - "url": "https://mt1.google.com/vt/lyrs=p&x={x}&y={y}&z={z}", - "attribution": "Google", - "name": "Google Terrain", - }, - "HYBRID": { - "url": "https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}", - "attribution": "Google", - "name": "Google Satellite", - }, -} - -# Custom WMS tile services. -wms_tiles = { - "FWS NWI Wetlands": { - "url": "https://www.fws.gov/wetlands/arcgis/services/Wetlands/MapServer/WMSServer?", - "layers": "1", - "name": "FWS NWI Wetlands", - "attribution": "FWS", - "format": "image/png", - "transparent": True, - }, - "FWS NWI Wetlands Raster": { - "url": "https://www.fws.gov/wetlands/arcgis/services/Wetlands_Raster/ImageServer/WMSServer?", - "layers": "0", - "name": "FWS NWI Wetlands Raster", - "attribution": "FWS", - "format": "image/png", - "transparent": True, - }, - "NLCD 2019 CONUS Land Cover": { - "url": "https://www.mrlc.gov/geoserver/mrlc_display/NLCD_2019_Land_Cover_L48/wms?", - "layers": "NLCD_2019_Land_Cover_L48", - "name": "NLCD 2019 CONUS Land Cover", - "attribution": "MRLC", - "format": "image/png", - "transparent": True, - }, - "NLCD 2016 CONUS Land Cover": { - "url": "https://www.mrlc.gov/geoserver/mrlc_display/NLCD_2016_Land_Cover_L48/wms?", - "layers": "NLCD_2016_Land_Cover_L48", - "name": "NLCD 2016 CONUS Land Cover", - "attribution": "MRLC", - "format": "image/png", - "transparent": True, - }, - "NLCD 2013 CONUS Land Cover": { - "url": "https://www.mrlc.gov/geoserver/mrlc_display/NLCD_2013_Land_Cover_L48/wms?", - "layers": "NLCD_2013_Land_Cover_L48", - "name": "NLCD 2013 CONUS Land Cover", - "attribution": "MRLC", - "format": "image/png", - "transparent": True, - }, - "NLCD 2011 CONUS Land Cover": { - "url": "https://www.mrlc.gov/geoserver/mrlc_display/NLCD_2011_Land_Cover_L48/wms?", - "layers": "NLCD_2011_Land_Cover_L48", - "name": "NLCD 2011 CONUS Land Cover", - "attribution": "MRLC", - "format": "image/png", - "transparent": True, - }, - "NLCD 2008 CONUS Land Cover": { - "url": "https://www.mrlc.gov/geoserver/mrlc_display/NLCD_2008_Land_Cover_L48/wms?", - "layers": "NLCD_2008_Land_Cover_L48", - "name": "NLCD 2008 CONUS Land Cover", - "attribution": "MRLC", - "format": "image/png", - "transparent": True, - }, - "NLCD 2006 CONUS Land Cover": { - "url": "https://www.mrlc.gov/geoserver/mrlc_display/NLCD_2006_Land_Cover_L48/wms?", - "layers": "NLCD_2006_Land_Cover_L48", - "name": "NLCD 2006 CONUS Land Cover", - "attribution": "MRLC", - "format": "image/png", - "transparent": True, - }, - "NLCD 2004 CONUS Land Cover": { - "url": "https://www.mrlc.gov/geoserver/mrlc_display/NLCD_2004_Land_Cover_L48/wms?", - "layers": "NLCD_2004_Land_Cover_L48", - "name": "NLCD 2004 CONUS Land Cover", - "attribution": "MRLC", - "format": "image/png", - "transparent": True, - }, - "NLCD 2001 CONUS Land Cover": { - "url": "https://www.mrlc.gov/geoserver/mrlc_display/NLCD_2001_Land_Cover_L48/wms?", - "layers": "NLCD_2001_Land_Cover_L48", - "name": "NLCD 2001 CONUS Land Cover", - "attribution": "MRLC", - "format": "image/png", - "transparent": True, - }, - "USGS NAIP Imagery": { - "url": "https://imagery.nationalmap.gov/arcgis/services/USGSNAIPImagery/ImageServer/WMSServer?", - "layers": "USGSNAIPImagery:NaturalColor", - "name": "USGS NAIP Imagery", - "attribution": "USGS", - "format": "image/png", - "transparent": True, - }, - "USGS NAIP Imagery False Color": { - "url": "https://imagery.nationalmap.gov/arcgis/services/USGSNAIPImagery/ImageServer/WMSServer?", - "layers": "USGSNAIPImagery:FalseColorComposite", - "name": "USGS NAIP Imagery False Color", - "attribution": "USGS", - "format": "image/png", - "transparent": True, - }, - "USGS NAIP Imagery NDVI": { - "url": "https://imagery.nationalmap.gov/arcgis/services/USGSNAIPImagery/ImageServer/WMSServer?", - "layers": "USGSNAIPImagery:NDVI_Color", - "name": "USGS NAIP Imagery NDVI", - "attribution": "USGS", - "format": "image/png", - "transparent": True, - }, - "USGS Hydrography": { - "url": "https://basemap.nationalmap.gov/arcgis/services/USGSHydroCached/MapServer/WMSServer?", - "layers": "0", - "name": "USGS Hydrography", - "attribution": "USGS", - "format": "image/png", - "transparent": True, - }, - "USGS 3DEP Elevation": { - "url": "https://elevation.nationalmap.gov/arcgis/services/3DEPElevation/ImageServer/WMSServer?", - "layers": "33DEPElevation:Hillshade Elevation Tinted", - "name": "USGS 3DEP Elevation", - "attribution": "USGS", - "format": "image/png", - "transparent": True, - }, - "ESA WorldCover 2020": { - "url": "https://services.terrascope.be/wms/v2", - "layers": "WORLDCOVER_2020_MAP", - "name": "ESA Worldcover 2020", - "attribution": "ESA", - "format": "image/png", - "transparent": True, - }, - "ESA WorldCover 2020 S2 FCC": { - "url": "https://services.terrascope.be/wms/v2", - "layers": "WORLDCOVER_2020_S2_FCC", - "name": "ESA Worldcover 2020 S2 FCC", - "attribution": "ESA", - "format": "image/png", - "transparent": True, - }, - "ESA WorldCover 2020 S2 TCC": { - "url": "https://services.terrascope.be/wms/v2", - "layers": "WORLDCOVER_2020_S2_TCC", - "name": "ESA Worldcover 2020 S2 TCC", - "attribution": "ESA", - "format": "image/png", - "transparent": True, - }, -} - - -def _unpack_sub_parameters(var, param): - temp = var - for sub_param in param.split("."): - temp = getattr(temp, sub_param) - return temp - - -def get_xyz_dict(free_only=True): - """Returns a dictionary of xyz services. - - Args: - free_only (bool, optional): Whether to return only free xyz tile services that do not require an access token. Defaults to True. - - Returns: - dict: A dictionary of xyz services. - """ - - xyz_dict = {} - for item in xyz.values(): - try: - name = item["name"] - tile = _unpack_sub_parameters(xyz, name) - if _unpack_sub_parameters(xyz, name).requires_token(): - if free_only: - pass - else: - xyz_dict[name] = tile - else: - xyz_dict[name] = tile - - except Exception: - for sub_item in item: - name = item[sub_item]["name"] - tile = _unpack_sub_parameters(xyz, name) - if _unpack_sub_parameters(xyz, name).requires_token(): - if free_only: - pass - else: - xyz_dict[name] = tile - else: - xyz_dict[name] = tile - - xyz_dict = collections.OrderedDict(sorted(xyz_dict.items())) - return xyz_dict - - -def xyz_to_folium(): - """Convert xyz tile services to folium tile layers. - - Returns: - dict: A dictionary of folium tile layers. - """ - folium_dict = {} - - for key in xyz_tiles: - name = xyz_tiles[key]["name"] - url = xyz_tiles[key]["url"] - attribution = xyz_tiles[key]["attribution"] - folium_dict[key] = folium.TileLayer( - tiles=url, - attr=attribution, - name=name, - overlay=True, - control=True, - max_zoom=22, - ) - - for key in wms_tiles: - name = wms_tiles[key]["name"] - url = wms_tiles[key]["url"] - layers = wms_tiles[key]["layers"] - fmt = wms_tiles[key]["format"] - transparent = wms_tiles[key]["transparent"] - attribution = wms_tiles[key]["attribution"] - folium_dict[key] = folium.WmsTileLayer( - url=url, - layers=layers, - name=name, - attr=attribution, - fmt=fmt, - transparent=transparent, - overlay=True, - control=True, - ) - - xyz_dict = get_xyz_dict() - for item in xyz_dict: - name = xyz_dict[item].name - url = xyz_dict[item].build_url() - attribution = xyz_dict[item].attribution - if "max_zoom" in xyz_dict[item].keys(): - max_zoom = xyz_dict[item]["max_zoom"] - else: - max_zoom = 22 - folium_dict[name] = folium.TileLayer( - tiles=url, - attr=attribution, - name=name, - max_zoom=max_zoom, - overlay=True, - control=True, - ) - - return folium_dict diff --git a/ecoscope/contrib/foliumap.py b/ecoscope/contrib/foliumap.py deleted file mode 100644 index ce6db896..00000000 --- a/ecoscope/contrib/foliumap.py +++ /dev/null @@ -1,2937 +0,0 @@ -""" -MIT License - -Copyright (c) 2021, Qiusheng Wu - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -""" - -__version__ = "be6ccdfa5450b775724547b6ef50f213c820be85" - -import os -import folium -import folium.plugins as plugins - -# from .common import * -from .basemaps import xyz_to_folium - -# from .osm import * - -builtin_legends = {} - - -from branca.element import Figure, JavascriptLink -from folium.map import Layer -from jinja2 import Template -from typing import Union - -basemaps = xyz_to_folium() - - -class Map(folium.Map): - """The Map class inherits folium.Map. By default, the Map will add OpenStreetMap as the basemap. - - Returns: - object: folium map object. - """ - - def __init__(self, **kwargs): - - # Default map center location and zoom level - latlon = [20, 0] - zoom = 2 - - # Interchangeable parameters between ipyleaflet and folium - if "center" in kwargs: - kwargs["location"] = kwargs["center"] - kwargs.pop("center") - if "location" in kwargs: - latlon = kwargs["location"] - else: - kwargs["location"] = latlon - - if "zoom" in kwargs: - kwargs["zoom_start"] = kwargs["zoom"] - kwargs.pop("zoom") - if "zoom_start" in kwargs: - zoom = kwargs["zoom_start"] - else: - kwargs["zoom_start"] = zoom - if "max_zoom" not in kwargs: - kwargs["max_zoom"] = 24 - - if "control_scale" not in kwargs: - kwargs["control_scale"] = True - - if "draw_export" not in kwargs: - kwargs["draw_export"] = False - - if "height" in kwargs and isinstance(kwargs["height"], str): - kwargs["height"] = float(kwargs["height"].replace("px", "")) - - if ( - "width" in kwargs - and isinstance(kwargs["width"], str) - and ("%" not in kwargs["width"]) - ): - kwargs["width"] = float(kwargs["width"].replace("px", "")) - - height = None - width = None - - if "height" in kwargs: - height = kwargs.pop("height") - - if "width" in kwargs: - width = kwargs.pop("width") - else: - width = "100%" - - super().__init__(**kwargs) - self.baseclass = "folium" - - if (height is not None) or (width is not None): - f = folium.Figure(width=width, height=height) - self.add_to(f) - - if "fullscreen_control" not in kwargs: - kwargs["fullscreen_control"] = True - if kwargs["fullscreen_control"]: - plugins.Fullscreen().add_to(self) - - if "draw_control" not in kwargs: - kwargs["draw_control"] = True - if kwargs["draw_control"]: - plugins.Draw(export=kwargs.get("draw_export")).add_to(self) - - if "measure_control" not in kwargs: - kwargs["measure_control"] = True - if kwargs["measure_control"]: - plugins.MeasureControl(position="bottomleft").add_to(self) - - if "latlon_control" not in kwargs: - kwargs["latlon_control"] = False - if kwargs["latlon_control"]: - folium.LatLngPopup().add_to(self) - - if "locate_control" not in kwargs: - kwargs["locate_control"] = False - if kwargs["locate_control"]: - plugins.LocateControl().add_to(self) - - if "minimap_control" not in kwargs: - kwargs["minimap_control"] = False - if kwargs["minimap_control"]: - plugins.MiniMap().add_to(self) - - if "search_control" not in kwargs: - kwargs["search_control"] = True - if kwargs["search_control"]: - plugins.Geocoder(collapsed=True, position="topleft").add_to(self) - - if "google_map" not in kwargs: - pass - elif kwargs["google_map"] is not None: - if kwargs["google_map"].upper() == "ROADMAP": - layer = basemaps["ROADMAP"] - elif kwargs["google_map"].upper() == "HYBRID": - layer = basemaps["HYBRID"] - elif kwargs["google_map"].upper() == "TERRAIN": - layer = basemaps["TERRAIN"] - elif kwargs["google_map"].upper() == "SATELLITE": - layer = basemaps["SATELLITE"] - else: - print( - f'{kwargs["google_map"]} is invalid. google_map must be one of: ["ROADMAP", "HYBRID", "TERRAIN", "SATELLITE"]. Adding the default ROADMAP.' - ) - layer = basemaps["ROADMAP"] - layer.add_to(self) - - if "layers_control" not in kwargs: - self.options["layersControl"] = True - else: - self.options["layersControl"] = kwargs["layers_control"] - - self.fit_bounds([latlon, latlon], max_zoom=zoom) - - def add_layer(self, layer): - """Adds a layer to the map. - - Args: - layer (TileLayer): A TileLayer instance. - """ - layer.add_to(self) - - def add_layer_control(self): - """Adds layer control to the map.""" - layer_ctrl = False - for item in self.to_dict()["children"]: - if item.startswith("layer_control"): - layer_ctrl = True - break - if not layer_ctrl: - folium.LayerControl().add_to(self) - - def _repr_mimebundle_(self, **kwargs): - """Adds Layer control to the map. Reference: https://ipython.readthedocs.io/en/stable/config/integrating.html#MyObject._repr_mimebundle_""" - if self.options["layersControl"]: - self.add_layer_control() - - def set_center(self, lon, lat, zoom=10): - """Centers the map view at a given coordinates with the given zoom level. - - Args: - lon (float): The longitude of the center, in degrees. - lat (float): The latitude of the center, in degrees. - zoom (int, optional): The zoom level, from 1 to 24. Defaults to 10. - """ - self.fit_bounds([[lat, lon], [lat, lon]], max_zoom=zoom) - - def zoom_to_bounds(self, bounds): - """Zooms to a bounding box in the form of [minx, miny, maxx, maxy]. - - Args: - bounds (list | tuple): A list/tuple containing minx, miny, maxx, maxy values for the bounds. - """ - # The folium fit_bounds method takes lat/lon bounds in the form [[south, west], [north, east]]. - self.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]]) - - def zoom_to_gdf(self, gdf): - """Zooms to the bounding box of a GeoPandas GeoDataFrame. - - Args: - gdf (GeoDataFrame): A GeoPandas GeoDataFrame. - """ - bounds = gdf.total_bounds - self.zoom_to_bounds(bounds) - - def add_basemap(self, basemap="HYBRID"): - """Adds a basemap to the map. - - Args: - basemap (str, optional): Can be one of string from ee_basemaps. Defaults to 'HYBRID'. - """ - import xyzservices - - try: - if isinstance(basemap, xyzservices.TileProvider): - name = basemap.name - url = basemap.build_url() - attribution = basemap.attribution - if "max_zoom" in basemap.keys(): - max_zoom = basemap["max_zoom"] - else: - max_zoom = 22 - layer = folium.TileLayer( - tiles=url, - attr=attribution, - name=name, - max_zoom=max_zoom, - overlay=True, - control=True, - ) - - self.add_layer(layer) - elif basemap in basemaps: - basemaps[basemap].add_to(self) - else: - print( - "Basemap can only be one of the following: {}".format( - ", ".join(basemaps.keys()) - ) - ) - except Exception: - raise Exception( - "Basemap can only be one of the following: {}".format( - ", ".join(basemaps.keys()) - ) - ) - - def add_wms_layer( - self, - url, - layers, - name=None, - attribution="", - overlay=True, - control=True, - shown=True, - format="image/png", - transparent=True, - version="1.1.1", - styles="", - **kwargs, - ): - - """Add a WMS layer to the map. - - Args: - url (str): The URL of the WMS web service. - layers (str): Comma-separated list of WMS layers to show. - name (str, optional): The layer name to use on the layer control. Defaults to None. - attribution (str, optional): The attribution of the data layer. Defaults to ''. - overlay (str, optional): Allows overlay. Defaults to True. - control (str, optional): Adds the layer to the layer control. Defaults to True. - shown (bool, optional): A flag indicating whether the layer should be on by default. Defaults to True. - format (str, optional): WMS image format (use ‘image/png’ for layers with transparency). Defaults to 'image/png'. - transparent (bool, optional): Whether the layer shall allow transparency. Defaults to True. - version (str, optional): Version of the WMS service to use. Defaults to "1.1.1". - styles (str, optional): Comma-separated list of WMS styles. Defaults to "". - """ - try: - folium.raster_layers.WmsTileLayer( - url=url, - layers=layers, - name=name, - attr=attribution, - overlay=overlay, - control=control, - show=shown, - styles=styles, - fmt=format, - transparent=transparent, - version=version, - **kwargs, - ).add_to(self) - except Exception as e: - raise Exception(e) - - def add_tile_layer( - self, - url, - name, - attribution, - overlay=True, - control=True, - shown=True, - opacity=1.0, - API_key=None, - **kwargs, - ): - """Add a XYZ tile layer to the map. - - Args: - url (str): The URL of the XYZ tile service. - name (str): The layer name to use on the layer control. - attribution (str): The attribution of the data layer. - overlay (str, optional): Allows overlay. Defaults to True. - control (str, optional): Adds the layer to the layer control. Defaults to True. - shown (bool, optional): A flag indicating whether the layer should be on by default. Defaults to True. - opacity (float, optional): Sets the opacity for the layer. - API_key (str, optional): – API key for Cloudmade or Mapbox tiles. Defaults to True. - """ - - try: - folium.raster_layers.TileLayer( - tiles=url, - name=name, - attr=attribution, - overlay=overlay, - control=control, - show=shown, - opacity=opacity, - API_key=API_key, - **kwargs, - ).add_to(self) - except Exception as e: - raise Exception(e) - - def add_local_tile( - self, - source, - band=None, - palette=None, - vmin=None, - vmax=None, - nodata=None, - attribution=None, - layer_name="Local COG", - **kwargs, - ): - """Add a local raster dataset to the map. - - If you are using this function in JupyterHub on a remote server (e.g., Binder, Microsoft Planetary Computer), - try adding to following two lines to the beginning of the notebook if the raster does not render properly. - - import os - os.environ['LOCALTILESERVER_CLIENT_PREFIX'] = f'{os.environ['JUPYTERHUB_SERVICE_PREFIX'].lstrip('/')}/proxy/{{port}}' - - Args: - source (str): The path to the GeoTIFF file or the URL of the Cloud Optimized GeoTIFF. - band (int, optional): The band to use. Band indexing starts at 1. Defaults to None. - palette (str, optional): The name of the color palette from `palettable` to use when plotting a single band. See https://jiffyclub.github.io/palettable. Default is greyscale - vmin (float, optional): The minimum value to use when colormapping the palette when plotting a single band. Defaults to None. - vmax (float, optional): The maximum value to use when colormapping the palette when plotting a single band. Defaults to None. - nodata (float, optional): The value from the band to use to interpret as not valid data. Defaults to None. - attribution (str, optional): Attribution for the source raster. This defaults to a message about it being a local file.. Defaults to None. - layer_name (str, optional): The layer name to use. Defaults to 'Local COG'. - """ - - tile_layer, tile_client = get_local_tile_layer( - source, - band=band, - palette=palette, - vmin=vmin, - vmax=vmax, - nodata=nodata, - attribution=attribution, - tile_format="folium", - layer_name=layer_name, - return_client=True, - **kwargs, - ) - self.add_layer(tile_layer) - - bounds = tile_client.bounds() # [ymin, ymax, xmin, xmax] - bounds = ( - bounds[2], - bounds[0], - bounds[3], - bounds[1], - ) # [minx, miny, maxx, maxy] - self.zoom_to_bounds(bounds) - - add_geotiff = add_local_tile - - def add_remote_tile( - self, - source, - band=None, - palette=None, - vmin=None, - vmax=None, - nodata=None, - attribution=None, - layer_name=None, - **kwargs, - ): - """Add a remote Cloud Optimized GeoTIFF (COG) to the map. - - Args: - source (str): The path to the remote Cloud Optimized GeoTIFF. - band (int, optional): The band to use. Band indexing starts at 1. Defaults to None. - palette (str, optional): The name of the color palette from `palettable` to use when plotting a single band. See https://jiffyclub.github.io/palettable. Default is greyscale - vmin (float, optional): The minimum value to use when colormapping the palette when plotting a single band. Defaults to None. - vmax (float, optional): The maximum value to use when colormapping the palette when plotting a single band. Defaults to None. - nodata (float, optional): The value from the band to use to interpret as not valid data. Defaults to None. - attribution (str, optional): Attribution for the source raster. This defaults to a message about it being a local file.. Defaults to None. - layer_name (str, optional): The layer name to use. Defaults to None. - """ - if isinstance(source, str) and source.startswith("http"): - self.add_local_tile( - source, - band=band, - palette=palette, - vmin=vmin, - vmax=vmax, - nodata=nodata, - attribution=attribution, - layer_name=layer_name, - **kwargs, - ) - else: - raise Exception("The source must be a URL.") - - def add_netcdf( - self, - filename, - variables=None, - palette=None, - vmin=None, - vmax=None, - nodata=None, - attribution=None, - layer_name="NetCDF layer", - shift_lon=True, - lat="lat", - lon="lon", - **kwargs, - ): - """Generate an ipyleaflet/folium TileLayer from a netCDF file. - If you are using this function in JupyterHub on a remote server (e.g., Binder, Microsoft Planetary Computer), - try adding to following two lines to the beginning of the notebook if the raster does not render properly. - - import os - os.environ['LOCALTILESERVER_CLIENT_PREFIX'] = f'{os.environ['JUPYTERHUB_SERVICE_PREFIX'].lstrip('/')}/proxy/{{port}}' - - Args: - filename (str): File path or HTTP URL to the netCDF file. - variables (int, optional): The variable/band names to extract data from the netCDF file. Defaults to None. If None, all variables will be extracted. - port (str, optional): The port to use for the server. Defaults to "default". - palette (str, optional): The name of the color palette from `palettable` to use when plotting a single band. See https://jiffyclub.github.io/palettable. Default is greyscale - vmin (float, optional): The minimum value to use when colormapping the palette when plotting a single band. Defaults to None. - vmax (float, optional): The maximum value to use when colormapping the palette when plotting a single band. Defaults to None. - nodata (float, optional): The value from the band to use to interpret as not valid data. Defaults to None. - attribution (str, optional): Attribution for the source raster. This defaults to a message about it being a local file.. Defaults to None. - layer_name (str, optional): The layer name to use. Defaults to "netCDF layer". - shift_lon (bool, optional): Flag to shift longitude values from [0, 360] to the range [-180, 180]. Defaults to True. - lat (str, optional): Name of the latitude variable. Defaults to 'lat'. - lon (str, optional): Name of the longitude variable. Defaults to 'lon'. - """ - - tif, vars = netcdf_to_tif( - filename, shift_lon=shift_lon, lat=lat, lon=lon, return_vars=True - ) - - if variables is None: - if len(vars) >= 3: - band_idx = [1, 2, 3] - else: - band_idx = [1] - else: - if not set(variables).issubset(set(vars)): - raise ValueError(f"The variables must be a subset of {vars}.") - else: - band_idx = [vars.index(v) + 1 for v in variables] - - self.add_local_tile( - tif, - band=band_idx, - palette=palette, - vmin=vmin, - vmax=vmax, - nodata=nodata, - attribution=attribution, - layer_name=layer_name, - **kwargs, - ) - - def add_heatmap( - self, - data, - latitude="latitude", - longitude="longitude", - value="value", - name="Heat map", - radius=25, - **kwargs, - ): - """Adds a heat map to the map. Reference: https://stackoverflow.com/a/54756617 - - Args: - data (str | list | pd.DataFrame): File path or HTTP URL to the input file or a list of data points in the format of [[x1, y1, z1], [x2, y2, z2]]. For example, https://raw.githubusercontent.com/giswqs/leafmap/master/examples/data/world_cities.csv - latitude (str, optional): The column name of latitude. Defaults to "latitude". - longitude (str, optional): The column name of longitude. Defaults to "longitude". - value (str, optional): The column name of values. Defaults to "value". - name (str, optional): Layer name to use. Defaults to "Heat map". - radius (int, optional): Radius of each “point” of the heatmap. Defaults to 25. - - Raises: - ValueError: If data is not a list. - """ - import pandas as pd - - try: - - if isinstance(data, str): - df = pd.read_csv(data) - data = df[[latitude, longitude, value]].values.tolist() - elif isinstance(data, pd.DataFrame): - data = data[[latitude, longitude, value]].values.tolist() - elif isinstance(data, list): - pass - else: - raise ValueError("data must be a list, a DataFrame, or a file path.") - - plugins.HeatMap(data, name=name, radius=radius, **kwargs).add_to( - folium.FeatureGroup(name=name).add_to(self) - ) - except Exception as e: - raise Exception(e) - - def add_osm_from_geocode( - self, - query, - which_result=None, - by_osmid=False, - buffer_dist=None, - layer_name="Untitled", - style={}, - hover_style={}, - style_callback=None, - fill_colors=["black"], - info_mode="on_hover", - ): - """Adds OSM data of place(s) by name or ID to the map. - - Args: - query (str | dict | list): Query string(s) or structured dict(s) to geocode. - which_result (int, optional): Which geocoding result to use. if None, auto-select the first (Multi)Polygon or raise an error if OSM doesn't return one. to get the top match regardless of geometry type, set which_result=1. Defaults to None. - by_osmid (bool, optional): If True, handle query as an OSM ID for lookup rather than text search. Defaults to False. - buffer_dist (float, optional): Distance to buffer around the place geometry, in meters. Defaults to None. - layer_name (str, optional): The layer name to be used.. Defaults to "Untitled". - style (dict, optional): A dictionary specifying the style to be used. Defaults to {}. - hover_style (dict, optional): Hover style dictionary. Defaults to {}. - style_callback (function, optional): Styling function that is called for each feature, and should return the feature style. This styling function takes the feature as argument. Defaults to None. - fill_colors (list, optional): The random colors to use for filling polygons. Defaults to ["black"]. - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - - """ - - gdf = osm_gdf_from_geocode( - query, which_result=which_result, by_osmid=by_osmid, buffer_dist=buffer_dist - ) - geojson = gdf.__geo_interface__ - - self.add_geojson( - geojson, - layer_name=layer_name, - style=style, - hover_style=hover_style, - style_callback=style_callback, - fill_colors=fill_colors, - info_mode=info_mode, - ) - self.zoom_to_gdf(gdf) - - def add_osm_from_address( - self, - address, - tags, - dist=1000, - layer_name="Untitled", - style={}, - hover_style={}, - style_callback=None, - fill_colors=["black"], - info_mode="on_hover", - ): - """Adds OSM entities within some distance N, S, E, W of address to the map. - - Args: - address (str): The address to geocode and use as the central point around which to get the geometries. - tags (dict): Dict of tags used for finding objects in the selected area. Results returned are the union, not intersection of each individual tag. Each result matches at least one given tag. The dict keys should be OSM tags, (e.g., building, landuse, highway, etc) and the dict values should be either True to retrieve all items with the given tag, or a string to get a single tag-value combination, or a list of strings to get multiple values for the given tag. For example, tags = {‘building’: True} would return all building footprints in the area. tags = {‘amenity’:True, ‘landuse’:[‘retail’,’commercial’], ‘highway’:’bus_stop’} would return all amenities, landuse=retail, landuse=commercial, and highway=bus_stop. - dist (int, optional): Distance in meters. Defaults to 1000. - layer_name (str, optional): The layer name to be used.. Defaults to "Untitled". - style (dict, optional): A dictionary specifying the style to be used. Defaults to {}. - hover_style (dict, optional): Hover style dictionary. Defaults to {}. - style_callback (function, optional): Styling function that is called for each feature, and should return the feature style. This styling function takes the feature as argument. Defaults to None. - fill_colors (list, optional): The random colors to use for filling polygons. Defaults to ["black"]. - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - - """ - gdf = osm_gdf_from_address(address, tags, dist) - geojson = gdf.__geo_interface__ - - self.add_geojson( - geojson, - layer_name=layer_name, - style=style, - hover_style=hover_style, - style_callback=style_callback, - fill_colors=fill_colors, - info_mode=info_mode, - ) - self.zoom_to_gdf(gdf) - - def add_osm_from_place( - self, - query, - tags, - which_result=None, - buffer_dist=None, - layer_name="Untitled", - style={}, - hover_style={}, - style_callback=None, - fill_colors=["black"], - info_mode="on_hover", - ): - """Adds OSM entities within boundaries of geocodable place(s) to the map. - - Args: - query (str | dict | list): Query string(s) or structured dict(s) to geocode. - tags (dict): Dict of tags used for finding objects in the selected area. Results returned are the union, not intersection of each individual tag. Each result matches at least one given tag. The dict keys should be OSM tags, (e.g., building, landuse, highway, etc) and the dict values should be either True to retrieve all items with the given tag, or a string to get a single tag-value combination, or a list of strings to get multiple values for the given tag. For example, tags = {‘building’: True} would return all building footprints in the area. tags = {‘amenity’:True, ‘landuse’:[‘retail’,’commercial’], ‘highway’:’bus_stop’} would return all amenities, landuse=retail, landuse=commercial, and highway=bus_stop. - which_result (int, optional): Which geocoding result to use. if None, auto-select the first (Multi)Polygon or raise an error if OSM doesn't return one. to get the top match regardless of geometry type, set which_result=1. Defaults to None. - buffer_dist (float, optional): Distance to buffer around the place geometry, in meters. Defaults to None. - layer_name (str, optional): The layer name to be used.. Defaults to "Untitled". - style (dict, optional): A dictionary specifying the style to be used. Defaults to {}. - hover_style (dict, optional): Hover style dictionary. Defaults to {}. - style_callback (function, optional): Styling function that is called for each feature, and should return the feature style. This styling function takes the feature as argument. Defaults to None. - fill_colors (list, optional): The random colors to use for filling polygons. Defaults to ["black"]. - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - - """ - gdf = osm_gdf_from_place(query, tags, which_result, buffer_dist) - geojson = gdf.__geo_interface__ - - self.add_geojson( - geojson, - layer_name=layer_name, - style=style, - hover_style=hover_style, - style_callback=style_callback, - fill_colors=fill_colors, - info_mode=info_mode, - ) - self.zoom_to_gdf(gdf) - - def add_osm_from_point( - self, - center_point, - tags, - dist=1000, - layer_name="Untitled", - style={}, - hover_style={}, - style_callback=None, - fill_colors=["black"], - info_mode="on_hover", - ): - """Adds OSM entities within some distance N, S, E, W of a point to the map. - - Args: - center_point (tuple): The (lat, lng) center point around which to get the geometries. - tags (dict): Dict of tags used for finding objects in the selected area. Results returned are the union, not intersection of each individual tag. Each result matches at least one given tag. The dict keys should be OSM tags, (e.g., building, landuse, highway, etc) and the dict values should be either True to retrieve all items with the given tag, or a string to get a single tag-value combination, or a list of strings to get multiple values for the given tag. For example, tags = {‘building’: True} would return all building footprints in the area. tags = {‘amenity’:True, ‘landuse’:[‘retail’,’commercial’], ‘highway’:’bus_stop’} would return all amenities, landuse=retail, landuse=commercial, and highway=bus_stop. - dist (int, optional): Distance in meters. Defaults to 1000. - layer_name (str, optional): The layer name to be used.. Defaults to "Untitled". - style (dict, optional): A dictionary specifying the style to be used. Defaults to {}. - hover_style (dict, optional): Hover style dictionary. Defaults to {}. - style_callback (function, optional): Styling function that is called for each feature, and should return the feature style. This styling function takes the feature as argument. Defaults to None. - fill_colors (list, optional): The random colors to use for filling polygons. Defaults to ["black"]. - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - - """ - gdf = osm_gdf_from_point(center_point, tags, dist) - geojson = gdf.__geo_interface__ - - self.add_geojson( - geojson, - layer_name=layer_name, - style=style, - hover_style=hover_style, - style_callback=style_callback, - fill_colors=fill_colors, - info_mode=info_mode, - ) - self.zoom_to_gdf(gdf) - - def add_osm_from_polygon( - self, - polygon, - tags, - layer_name="Untitled", - style={}, - hover_style={}, - style_callback=None, - fill_colors=["black"], - info_mode="on_hover", - ): - """Adds OSM entities within boundaries of a (multi)polygon to the map. - - Args: - polygon (shapely.geometry.Polygon | shapely.geometry.MultiPolygon): Geographic boundaries to fetch geometries within - tags (dict): Dict of tags used for finding objects in the selected area. Results returned are the union, not intersection of each individual tag. Each result matches at least one given tag. The dict keys should be OSM tags, (e.g., building, landuse, highway, etc) and the dict values should be either True to retrieve all items with the given tag, or a string to get a single tag-value combination, or a list of strings to get multiple values for the given tag. For example, tags = {‘building’: True} would return all building footprints in the area. tags = {‘amenity’:True, ‘landuse’:[‘retail’,’commercial’], ‘highway’:’bus_stop’} would return all amenities, landuse=retail, landuse=commercial, and highway=bus_stop. - layer_name (str, optional): The layer name to be used.. Defaults to "Untitled". - style (dict, optional): A dictionary specifying the style to be used. Defaults to {}. - hover_style (dict, optional): Hover style dictionary. Defaults to {}. - style_callback (function, optional): Styling function that is called for each feature, and should return the feature style. This styling function takes the feature as argument. Defaults to None. - fill_colors (list, optional): The random colors to use for filling polygons. Defaults to ["black"]. - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - - """ - gdf = osm_gdf_from_polygon(polygon, tags) - geojson = gdf.__geo_interface__ - - self.add_geojson( - geojson, - layer_name=layer_name, - style=style, - hover_style=hover_style, - style_callback=style_callback, - fill_colors=fill_colors, - info_mode=info_mode, - ) - self.zoom_to_gdf(gdf) - - def add_osm_from_bbox( - self, - north, - south, - east, - west, - tags, - layer_name="Untitled", - style={}, - hover_style={}, - style_callback=None, - fill_colors=["black"], - info_mode="on_hover", - ): - """Adds OSM entities within a N, S, E, W bounding box to the map. - - - Args: - north (float): Northern latitude of bounding box. - south (float): Southern latitude of bounding box. - east (float): Eastern longitude of bounding box. - west (float): Western longitude of bounding box. - tags (dict): Dict of tags used for finding objects in the selected area. Results returned are the union, not intersection of each individual tag. Each result matches at least one given tag. The dict keys should be OSM tags, (e.g., building, landuse, highway, etc) and the dict values should be either True to retrieve all items with the given tag, or a string to get a single tag-value combination, or a list of strings to get multiple values for the given tag. For example, tags = {‘building’: True} would return all building footprints in the area. tags = {‘amenity’:True, ‘landuse’:[‘retail’,’commercial’], ‘highway’:’bus_stop’} would return all amenities, landuse=retail, landuse=commercial, and highway=bus_stop. - layer_name (str, optional): The layer name to be used.. Defaults to "Untitled". - style (dict, optional): A dictionary specifying the style to be used. Defaults to {}. - hover_style (dict, optional): Hover style dictionary. Defaults to {}. - style_callback (function, optional): Styling function that is called for each feature, and should return the feature style. This styling function takes the feature as argument. Defaults to None. - fill_colors (list, optional): The random colors to use for filling polygons. Defaults to ["black"]. - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - - """ - gdf = osm_gdf_from_bbox(north, south, east, west, tags) - geojson = gdf.__geo_interface__ - - self.add_geojson( - geojson, - layer_name=layer_name, - style=style, - hover_style=hover_style, - style_callback=style_callback, - fill_colors=fill_colors, - info_mode=info_mode, - ) - self.zoom_to_gdf(gdf) - - def add_osm_from_view( - self, - tags, - layer_name="Untitled", - style={}, - hover_style={}, - style_callback=None, - fill_colors=["black"], - info_mode="on_hover", - ): - """Adds OSM entities within the current map view to the map. - - Args: - tags (dict): Dict of tags used for finding objects in the selected area. Results returned are the union, not intersection of each individual tag. Each result matches at least one given tag. The dict keys should be OSM tags, (e.g., building, landuse, highway, etc) and the dict values should be either True to retrieve all items with the given tag, or a string to get a single tag-value combination, or a list of strings to get multiple values for the given tag. For example, tags = {‘building’: True} would return all building footprints in the area. tags = {‘amenity’:True, ‘landuse’:[‘retail’,’commercial’], ‘highway’:’bus_stop’} would return all amenities, landuse=retail, landuse=commercial, and highway=bus_stop. - layer_name (str, optional): The layer name to be used.. Defaults to "Untitled". - style (dict, optional): A dictionary specifying the style to be used. Defaults to {}. - hover_style (dict, optional): Hover style dictionary. Defaults to {}. - style_callback (function, optional): Styling function that is called for each feature, and should return the feature style. This styling function takes the feature as argument. Defaults to None. - fill_colors (list, optional): The random colors to use for filling polygons. Defaults to ["black"]. - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - - """ - pass # folium can't get map bounds. See https://github.com/python-visualization/folium/issues/1118 - # bounds = self.get_bounds() - # north, south, east, west = ( - # bounds[1][0], - # bounds[0][0], - # bounds[1][1], - # bounds[0][1], - # ) - - # gdf = osm_gdf_from_bbox(north, south, east, west, tags) - # geojson = gdf.__geo_interface__ - - # self.add_geojson( - # geojson, - # layer_name=layer_name, - # style=style, - # hover_style=hover_style, - # style_callback=style_callback, - # fill_colors=fill_colors, - # info_mode=info_mode, - # ) - # self.zoom_to_gdf(gdf) - - def add_cog_layer( - self, - url, - name="Untitled", - attribution=".", - opacity=1.0, - shown=True, - bands=None, - titiler_endpoint="https://titiler.xyz", - **kwargs, - ): - """Adds a COG TileLayer to the map. - - Args: - url (str): The URL of the COG tile layer. - name (str, optional): The layer name to use for the layer. Defaults to 'Untitled'. - attribution (str, optional): The attribution to use. Defaults to '.'. - opacity (float, optional): The opacity of the layer. Defaults to 1. - shown (bool, optional): A flag indicating whether the layer should be on by default. Defaults to True. - bands (list, optional): A list of bands to use. Defaults to None. - titiler_endpoint (str, optional): Titiler endpoint. Defaults to "https://titiler.xyz". - """ - tile_url = cog_tile(url, bands, titiler_endpoint, **kwargs) - bounds = cog_bounds(url, titiler_endpoint) - self.add_tile_layer( - url=tile_url, - name=name, - attribution=attribution, - opacity=opacity, - shown=shown, - ) - self.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]]) - - def add_cog_mosaic(self, **kwargs): - raise NotImplementedError( - "This function is no longer supported.See https://github.com/giswqs/leafmap/issues/180." - ) - - def add_cog_mosaic_from_file(self, **kwargs): - raise NotImplementedError( - "This function is no longer supported.See https://github.com/giswqs/leafmap/issues/180." - ) - - def add_stac_layer( - self, - url=None, - collection=None, - item=None, - assets=None, - bands=None, - titiler_endpoint=None, - name="STAC Layer", - attribution=".", - opacity=1.0, - shown=True, - **kwargs, - ): - """Adds a STAC TileLayer to the map. - - Args: - url (str): HTTP URL to a STAC item, e.g., https://canada-spot-ortho.s3.amazonaws.com/canada_spot_orthoimages/canada_spot5_orthoimages/S5_2007/S5_11055_6057_20070622/S5_11055_6057_20070622.json - collection (str): The Microsoft Planetary Computer STAC collection ID, e.g., landsat-8-c2-l2. - item (str): The Microsoft Planetary Computer STAC item ID, e.g., LC08_L2SP_047027_20201204_02_T1. - assets (str | list): The Microsoft Planetary Computer STAC asset ID, e.g., ["SR_B7", "SR_B5", "SR_B4"]. - bands (list): A list of band names, e.g., ["SR_B7", "SR_B5", "SR_B4"] - titiler_endpoint (str, optional): Titiler endpoint, e.g., "https://titiler.xyz", "planetary-computer", "pc". Defaults to None. - name (str, optional): The layer name to use for the layer. Defaults to 'STAC Layer'. - attribution (str, optional): The attribution to use. Defaults to ''. - opacity (float, optional): The opacity of the layer. Defaults to 1. - shown (bool, optional): A flag indicating whether the layer should be on by default. Defaults to True. - """ - tile_url = stac_tile( - url, collection, item, assets, bands, titiler_endpoint, **kwargs - ) - bounds = stac_bounds(url, collection, item, titiler_endpoint) - self.add_tile_layer( - url=tile_url, - name=name, - attribution=attribution, - opacity=opacity, - shown=shown, - ) - self.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]]) - - def add_mosaic_layer( - self, - url, - titiler_endpoint=None, - name="Mosaic Layer", - attribution=".", - opacity=1.0, - shown=True, - **kwargs, - ): - """Adds a STAC TileLayer to the map. - - Args: - url (str): HTTP URL to a MosaicJSON. - titiler_endpoint (str, optional): Titiler endpoint, e.g., "https://titiler.xyz". Defaults to None. - name (str, optional): The layer name to use for the layer. Defaults to 'Mosaic Layer'. - attribution (str, optional): The attribution to use. Defaults to ''. - opacity (float, optional): The opacity of the layer. Defaults to 1. - shown (bool, optional): A flag indicating whether the layer should be on by default. Defaults to True. - """ - tile_url = mosaic_tile(url, titiler_endpoint, **kwargs) - bounds = mosaic_bounds(url, titiler_endpoint) - self.add_tile_layer( - url=tile_url, - name=name, - attribution=attribution, - opacity=opacity, - shown=shown, - ) - self.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]]) - - def add_legend( - self, - title="Legend", - colors=None, - labels=None, - legend_dict=None, - builtin_legend=None, - opacity=1.0, - title_styles:dict=None, - background_color:Union[str,tuple]=(255,255,255,0.8), - border_styles:Union[str, dict]="2px solid gray", - box_position:dict={"bottom":"20px", "right":"20px"}, - **kwargs, - ): - """Adds a customized legend to the map. Reference: https://bit.ly/3oV6vnH - - Args: - title (str, optional): Title of the legend. Defaults to 'Legend'. Defaults to "Legend". - colors (list, optional): A list of legend colors. Defaults to None. - labels (list, optional): A list of legend labels. Defaults to None. - legend_dict (dict, optional): A dictionary containing legend items as keys and color as values. If provided, legend_keys and legend_colors will be ignored. Defaults to None. - builtin_legend (str, optional): Name of the builtin legend to add to the map. Defaults to None. - opacity (float, optional): The opacity of the legend. Defaults to 1.0. - title_styles (dict, optional): A dictionary containing font-family, size and color properties, provided with the following structure: - ex. { - "font": "Helvetica", - "size": "12px", - "color": "rgba(0,0,0,1)" - } - background_color (str, tuple, optional): Container background color. Could be passed as an hex string (with or without #), or as an rgb/rgba tuple. - border_styles (str, dict, optional): Space separated string for borders 'size style color radius' or dict structure with same fields definition. - ex. { - "width": "3px", - "style": "solid", - "color": "gray" - "radius": "6px" - } - box_position (dict, optional): Dictionary with box position values; combination of one x axis (left or right) and one y axis (bottom or top) values. - ex. { - "bottom": "40px", - "right": "20px - } - Default: {"bottom":"20px", "right":"20px"} - """ - from branca.element import Template, MacroElement - - import os - - legend_template = os.path.join(os.path.dirname(__file__), "legend.txt") - - if not os.path.exists(legend_template): - raise FileNotFoundError("The legend template does not exist.") - - if labels is not None: - if not isinstance(labels, list): - raise ValueError("The legend labels must be a list.") - else: - labels = ["One", "Two", "Three", "Four", "etc"] - - if colors is not None: - if not isinstance(colors, list): - raise ValueError("The legend colors must be a list.") - elif all(isinstance(item, tuple) for item in colors): - try: - colors = ["#" + rgb_to_hex(x) for x in colors] - except Exception as e: - raise Exception(e) - 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 ValueError("The legend colors must be a list of tuples.") - else: - colors = ["#8DD3C7", "#FFFFB3", "#BEBADA", "#FB8072", "#80B1D3"] - - if len(labels) != len(colors): - raise ValueError("The legend keys and values must be the same length.") - - allowed_builtin_legends = builtin_legends.keys() - if builtin_legend is not None: - if builtin_legend not in allowed_builtin_legends: - raise ValueError( - "The builtin legend must be one of the following: {}".format( - ", ".join(allowed_builtin_legends) - ) - ) - else: - legend_dict = builtin_legends[builtin_legend] - labels = list(legend_dict.keys()) - colors = list(legend_dict.values()) - if all(isinstance(item, tuple) for item in colors): - try: - colors = [rgb_to_hex(x) for x in colors] - except Exception as e: - raise Exception(e) - elif all(isinstance(item, str) for item in colors): - colors = ["#" + color for color in colors] - - if legend_dict is not None: - if not isinstance(legend_dict, dict): - raise ValueError("The legend dict must be a dictionary.") - else: - labels = list(legend_dict.keys()) - colors = list(legend_dict.values()) - - if all(isinstance(item, tuple) for item in colors): - try: - colors = ["#" + rgb_to_hex(x) for x in colors] - except Exception as e: - raise Exception(e) - elif all((item.startswith("#") and len(item) == 7) for item in colors): - pass - elif all(isinstance(item, str) for item in colors): - colors = ["#" + color for color in colors] - - content = [] - - with open(legend_template) as f: - lines = f.readlines() - r_l_aux = False - t_b_aux = False - for index, line in enumerate(lines): - if index < 36: - content.append(line) - - - elif index == 36: - if isinstance(border_styles, str): - border_options = border_styles.split(" ") - if len(border_options)<3: continue - if len(border_options) == 3: - border = "{} {} {}".format(*[op for op in border_options]) - elif len(border_options) == 4: - border = "{} {} {}".format(*[op for op in border_options[:3]]) - #border_radius = f"{border_options[3]}px" - elif isinstance(border_styles, dict): - border_color = "" - if isinstance(border_styles["color"], tuple): - if border_styles["color"].__len__() == 3: - border_color = "rgb({},{},{})".format(*[c for c in border_styles["color"]]) - elif border_styles["color"].__len__() == 4: - border_color = "rgba({},{},{},{})".format(*[c for c in border_styles["color"]]) - elif isinstance(border_styles["color"], str): - border_color = border_styles["color"] if border_styles["color"].startswith("#") else f"#{border_styles['color']}" - - border = "{} {} {}".format( - border_styles["width"], - border_styles["style"], - border_color - ) - - line = lines[index].replace("2px solid grey", border) - content.append(line) - - - elif (index==37) and (background_color is not None): - bkg = "" - if isinstance(background_color, tuple): - if background_color.__len__() == 3: - bkg = "rgb({},{},{})".format(*[c for c in background_color]) - elif background_color.__len__() == 4: - bkg = "rgba({},{},{},{})".format(*[c for c in background_color]) - elif isinstance(background_color, str): - bkg = background_color if background_color.startswith("#") else f"#{background_color}" - line = lines[index].replace("rgba(255, 255, 255, 0.8)", bkg) - content.append(line) - - - elif index == 38: - if isinstance(border_styles, str): - border_options = border_styles.split(" ") - if len(border_options) == 4: - radius = border_options[-1] - else: continue - elif isinstance(border_styles, dict): - radius = border_styles["radius"] - else: continue - - line = lines[index].replace("6px", radius) - content.append(line) - - elif (index > 38) and (index < 41): - content.append(line) - - - - elif (index==41) and (box_position is not None): - if isinstance(box_position, dict): - if "right" not in box_position.keys(): - r_l_aux = True - line = line - elif box_position['right']=='': - r_l_aux = True - line = line - elif ("right" in box_position.keys()) and not ("left" in box_position.keys()) and (list(box_position['right'])[0]!='0'): - line = lines[index].replace("20px", box_position['right']) - elif (list(box_position['right'])[0]!='0') and (box_position["left"] != "") and not (box_position['left'] in ['0px', '0%', '0em', '0']): - line = lines[index].replace("right: 20px;", "") - else: - r_l_aux = True - line = line - - content.append(line) - elif (index==42) and (box_position is not None): - if isinstance(box_position, dict): - if "bottom" not in box_position.keys(): - t_b_aux = True - line = line - elif box_position['bottom']=='': - t_b_aux = True - line = line - elif ("bottom" in box_position.keys()) and not ("top" in box_position.keys()) and (list(box_position['bottom'])[0]!='0'): - line = lines[index].replace("20px", box_position['bottom']) - elif (list(box_position['bottom'])[0]!='0') and (box_position["top"] != "") and not (box_position['top'] in ['0px', '0%', '0em', '0']): - line = lines[index].replace("bottom: 20px;", "") - else: - t_b_aux = True - line = line - content.append(line) - - elif (index==43) and (box_position is not None): - if isinstance(box_position, dict): - if "top" not in box_position.keys(): - line = lines[index].replace("top", "") - elif box_position['top']=='': - line = lines[index].replace("top", "") - elif ("top" in box_position.keys()) and not ("bottom" in box_position.keys()) and not (list(box_position['top'])[0] in ['0', '']): - line = lines[index].replace("top", f"top: {box_position['top']};") - elif ("top" in box_position.keys()) and (box_position["top"]!="") and t_b_aux and (list(box_position['top'])[0]!='0'): - content[-1] = content[-3].replace("bottom: 20px;", "") - line = lines[index].replace("top", f"top: {box_position['top']};") - elif ("top" in box_position.keys() and box_position["top"]!="") and t_b_aux==False: - line = lines[index].replace("top", "") - else: - line = lines[index].replace("top", "") - content.append(line) - elif (index==44) and (box_position is not None): - if isinstance(box_position, dict): - if "left" not in box_position.keys(): - line = lines[index].replace("left", "") - elif box_position['left']=='': - line = lines[index].replace("left", "") - elif ("left" in box_position.keys()) and not ("right" in box_position.keys()): - content[-3] = content[-3].replace("right: 20px;", "") - line = lines[index].replace("left", f"left: {box_position['left']};") - elif ("left" in box_position.keys()) and (box_position["left"]!="") and r_l_aux and (list(box_position['left'])[0]!='0'): - content[-3] = content[-3].replace("right: 20px;", "") - line = lines[index].replace("left", f"left: {box_position['left']};") - elif ("left" in box_position.keys() and box_position["left"]!="") and r_l_aux==False: - line = lines[index].replace("left", "") - else: - line = lines[index].replace("left", "") - content.append(line) - - elif (index > 44) and (index < 47): - content.append(line) - - - - elif index == 47: - line = lines[index].replace("Legend", title) - content.append(line) - - elif index in [48,49]: - content.append(line) - - - elif (index > 49) and (index < 52): - if (labels is not None) and (index in [50,51]):continue - content.append(line) - - - elif index == 52: - for i, color in enumerate(colors): - item = f"
  • {labels[i]}
  • \n" - content.append(item) - - - elif (index > 52) and (index < 65): - content.append(line) - - - - elif (index==65) and (title_styles is not None): - if isinstance(title_styles, dict): - if "size" in title_styles.keys(): - line = lines[index].replace("90%", title_styles['size']) - content.append(line) - elif (index==66) and (title_styles is not None): - if isinstance(title_styles, dict): - if "font" in title_styles.keys(): - line = lines[index].replace("Helvetica", title_styles['font']) - content.append(line) - elif (index==67) and (title_styles is not None): - if isinstance(title_styles, dict): - if "color" in title_styles.keys(): - bkg = "" - if isinstance(title_styles["color"], tuple): - if title_styles["color"].__len__() == 3: - bkg = "rgb({},{},{})".format(*[c for c in title_styles["color"]]) - elif title_styles["color"].__len__() == 4: - bkg = "rgba({},{},{},{})".format(*[c for c in title_styles["color"]]) - elif isinstance(title_styles["color"], str): - bkg = title_styles["color"] if title_styles["color"].startswith("#") else f"#{title_styles['color']}" - line = lines[index].replace("rgba(0, 0, 0, 1)", bkg) - content.append(line) - elif (index > 67): - content.append(line) - - template = "".join(content) - macro = MacroElement() - macro._template = Template(template) - - self.get_root().add_child(macro) - - def add_colorbar( - self, - colors, - vmin=0, - vmax=1.0, - index=None, - caption="", - categorical=False, - step=None, - **kwargs, - ): - """Add a colorbar to the map. - - Args: - colors (list): 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”) - vmin (int, optional): The minimal value for the colormap. Values lower than vmin will be bound directly to colors[0].. Defaults to 0. - vmax (float, optional): The maximal value for the colormap. Values higher than vmax will be bound directly to colors[-1]. Defaults to 1.0. - index (list, optional):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. - caption (str, optional): The caption for the colormap. Defaults to "". - categorical (bool, optional): Whether or not to create a categorical colormap. Defaults to False. - step (int, optional): The step to split the LinearColormap into a StepColormap. Defaults to None. - """ - from box import Box - from branca.colormap import LinearColormap - - if isinstance(colors, Box): - try: - colors = list(colors["default"]) - except Exception as e: - print("The provided color list is invalid.") - raise Exception(e) - - if all(len(color) == 6 for color in colors): - colors = ["#" + color for color in colors] - - colormap = LinearColormap( - colors=colors, index=index, vmin=vmin, vmax=vmax, caption=caption - ) - - if categorical: - if step is not None: - colormap = colormap.to_step(step) - elif index is not None: - colormap = colormap.to_step(len(index) - 1) - else: - colormap = colormap.to_step(3) - - self.add_child(colormap) - - def add_shp(self, in_shp, layer_name="Untitled", info_mode="on_hover", **kwargs): - """Adds a shapefile to the map. See https://python-visualization.github.io/folium/modules.html#folium.features.GeoJson for more info about setting style. - - Args: - in_shp (str): The input file path to the shapefile. - layer_name (str, optional): The layer name to be used. Defaults to "Untitled". - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - - Raises: - FileNotFoundError: The provided shapefile could not be found. - """ - import glob - - if in_shp.startswith("http") and in_shp.endswith(".zip"): - out_dir = os.path.abspath("./cache/shp") - if not os.path.exists(out_dir): - os.makedirs(out_dir) - download_from_url(in_shp, out_dir=out_dir, verbose=False) - files = list(glob.glob(os.path.join(out_dir, "*.shp"))) - if len(files) > 0: - in_shp = files[0] - else: - raise FileNotFoundError( - "The downloaded zip file does not contain any shapefile in the root directory." - ) - else: - in_shp = os.path.abspath(in_shp) - if not os.path.exists(in_shp): - raise FileNotFoundError("The provided shapefile could not be found.") - - data = shp_to_geojson(in_shp) - - self.add_geojson(data, layer_name=layer_name, info_mode=info_mode, **kwargs) - - def add_geojson( - self, - in_geojson, - layer_name="Untitled", - encoding="utf-8", - info_mode="on_hover", - **kwargs, - ): - """Adds a GeoJSON file to the map. - - Args: - in_geojson (str): The input file path to the GeoJSON. - layer_name (str, optional): The layer name to be used. Defaults to "Untitled". - encoding (str, optional): The encoding of the GeoJSON file. Defaults to "utf-8". - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - - Raises: - FileNotFoundError: The provided GeoJSON file could not be found. - """ - import json - import requests - import random - - try: - - if isinstance(in_geojson, str): - - if in_geojson.startswith("http"): - data = requests.get(in_geojson).json() - else: - in_geojson = os.path.abspath(in_geojson) - if not os.path.exists(in_geojson): - raise FileNotFoundError( - "The provided GeoJSON file could not be found." - ) - - with open(in_geojson, encoding=encoding) as f: - data = json.load(f) - elif isinstance(in_geojson, dict): - data = in_geojson - else: - raise TypeError("The input geojson must be a type of str or dict.") - except Exception as e: - raise Exception(e) - - # interchangeable parameters between ipyleaflet and folium. - if "style_function" not in kwargs: - if "style" in kwargs: - style_dict = kwargs["style"] - if isinstance(kwargs["style"], dict) and len(kwargs["style"]) > 0: - kwargs["style_function"] = lambda x: style_dict - kwargs.pop("style") - else: - style_dict = { - # "stroke": True, - "color": "#000000", - "weight": 1, - "opacity": 1, - # "fill": True, - # "fillColor": "#ffffff", - "fillOpacity": 0.1, - # "dashArray": "9" - # "clickable": True, - } - kwargs["style_function"] = lambda x: style_dict - - if "style_callback" in kwargs: - kwargs.pop("style_callback") - - if "hover_style" in kwargs: - kwargs.pop("hover_style") - - if "fill_colors" in kwargs: - fill_colors = kwargs["fill_colors"] - - def random_color(feature): - style_dict["fillColor"] = random.choice(fill_colors) - return style_dict - - kwargs["style_function"] = random_color - kwargs.pop("fill_colors") - - if "highlight_function" not in kwargs: - kwargs["highlight_function"] = lambda feat: { - "weight": 2, - "fillOpacity": 0.5, - } - - tooltip = None - popup = None - if info_mode is not None: - props = list(data["features"][0]["properties"].keys()) - if info_mode == "on_hover": - tooltip = folium.GeoJsonTooltip(fields=props) - elif info_mode == "on_click": - popup = folium.GeoJsonPopup(fields=props) - - geojson = folium.GeoJson( - data=data, name=layer_name, tooltip=tooltip, popup=popup, **kwargs - ) - geojson.add_to(self) - - def add_gdf( - self, - gdf, - layer_name="Untitled", - zoom_to_layer=True, - info_mode="on_hover", - **kwargs, - ): - """Adds a GeoPandas GeoDataFrameto the map. - - Args: - gdf (GeoDataFrame): A GeoPandas GeoDataFrame. - layer_name (str, optional): The layer name to be used. Defaults to "Untitled". - zoom_to_layer (bool, optional): Whether to zoom to the layer. - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - - """ - - data = gdf_to_geojson(gdf, epsg="4326") - - self.add_geojson(data, layer_name=layer_name, info_mode=info_mode, **kwargs) - - if zoom_to_layer: - import numpy as np - - bounds = gdf.to_crs(epsg="4326").bounds - west = np.min(bounds["minx"]) - south = np.min(bounds["miny"]) - east = np.max(bounds["maxx"]) - north = np.max(bounds["maxy"]) - self.fit_bounds([[south, east], [north, west]]) - - def add_gdf_from_postgis( - self, - sql, - con, - layer_name="Untitled", - zoom_to_layer=True, - info_mode="on_hover", - **kwargs, - ): - """Adds a GeoPandas GeoDataFrameto the map. - - Args: - sql (str): SQL query to execute in selecting entries from database, or name of the table to read from the database. - con (sqlalchemy.engine.Engine): Active connection to the database to query. - layer_name (str, optional): The layer name to be used. Defaults to "Untitled". - zoom_to_layer (bool, optional): Whether to zoom to the layer. - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - - """ - if "fill_colors" in kwargs: - kwargs.pop("fill_colors") - gdf = read_postgis(sql, con, **kwargs) - data = gdf_to_geojson(gdf, epsg="4326") - - self.add_geojson(data, layer_name=layer_name, info_mode=info_mode, **kwargs) - - if zoom_to_layer: - import numpy as np - - bounds = gdf.to_crs(epsg="4326").bounds - west = np.min(bounds["minx"]) - south = np.min(bounds["miny"]) - east = np.max(bounds["maxx"]) - north = np.max(bounds["maxy"]) - self.fit_bounds([[south, east], [north, west]]) - - def add_kml(self, in_kml, layer_name="Untitled", info_mode="on_hover", **kwargs): - """Adds a KML file to the map. - - Args: - in_kml (str): The input file path to the KML. - layer_name (str, optional): The layer name to be used. Defaults to "Untitled". - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - - Raises: - FileNotFoundError: The provided KML file could not be found. - """ - - if in_kml.startswith("http") and in_kml.endswith(".kml"): - out_dir = os.path.abspath("./cache") - if not os.path.exists(out_dir): - os.makedirs(out_dir) - download_from_url(in_kml, out_dir=out_dir, unzip=False, verbose=False) - in_kml = os.path.join(out_dir, os.path.basename(in_kml)) - if not os.path.exists(in_kml): - raise FileNotFoundError("The downloaded kml file could not be found.") - else: - in_kml = os.path.abspath(in_kml) - if not os.path.exists(in_kml): - raise FileNotFoundError("The provided KML could not be found.") - - data = kml_to_geojson(in_kml) - - self.add_geojson(data, layer_name=layer_name, info_mode=info_mode, **kwargs) - - def add_vector( - self, - filename, - layer_name="Untitled", - bbox=None, - mask=None, - rows=None, - info_mode="on_hover", - **kwargs, - ): - """Adds any geopandas-supported vector dataset to the map. - - Args: - filename (str): Either the absolute or relative path to the file or URL to be opened, or any object with a read() method (such as an open file or StringIO). - layer_name (str, optional): The layer name to use. Defaults to "Untitled". - bbox (tuple | GeoDataFrame or GeoSeries | shapely Geometry, optional): Filter features by given bounding box, GeoSeries, GeoDataFrame or a shapely geometry. CRS mis-matches are resolved if given a GeoSeries or GeoDataFrame. Cannot be used with mask. Defaults to None. - mask (dict | GeoDataFrame or GeoSeries | shapely Geometry, optional): Filter for features that intersect with the given dict-like geojson geometry, GeoSeries, GeoDataFrame or shapely geometry. CRS mis-matches are resolved if given a GeoSeries or GeoDataFrame. Cannot be used with bbox. Defaults to None. - rows (int or slice, optional): Load in specific rows by passing an integer (first n rows) or a slice() object.. Defaults to None. - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - - """ - if not filename.startswith("http"): - filename = os.path.abspath(filename) - - ext = os.path.splitext(filename)[1].lower() - if ext == ".shp": - self.add_shp(filename, layer_name, **kwargs) - elif ext in [".json", ".geojson"]: - self.add_geojson(filename, layer_name, **kwargs) - else: - geojson = vector_to_geojson( - filename, - bbox=bbox, - mask=mask, - rows=rows, - epsg="4326", - **kwargs, - ) - - self.add_geojson(geojson, layer_name, info_mode=info_mode, **kwargs) - - def add_planet_by_month( - self, year=2016, month=1, name=None, api_key=None, token_name="PLANET_API_KEY" - ): - """Adds a Planet global mosaic by month to the map. To get a Planet API key, see https://developers.planet.com/quickstart/apis - - Args: - year (int, optional): The year of Planet global mosaic, must be >=2016. Defaults to 2016. - month (int, optional): The month of Planet global mosaic, must be 1-12. Defaults to 1. - name (str, optional): The layer name to use. Defaults to None. - api_key (str, optional): The Planet API key. Defaults to None. - token_name (str, optional): The environment variable name of the API key. Defaults to "PLANET_API_KEY". - """ - layer = planet_tile_by_month( - year, month, name, api_key, token_name, tile_format="folium" - ) - layer.add_to(self) - - def add_planet_by_quarter( - self, year=2016, quarter=1, name=None, api_key=None, token_name="PLANET_API_KEY" - ): - """Adds a Planet global mosaic by quarter to the map. To get a Planet API key, see https://developers.planet.com/quickstart/apis - - Args: - year (int, optional): The year of Planet global mosaic, must be >=2016. Defaults to 2016. - quarter (int, optional): The quarter of Planet global mosaic, must be 1-12. Defaults to 1. - name (str, optional): The layer name to use. Defaults to None. - api_key (str, optional): The Planet API key. Defaults to None. - token_name (str, optional): The environment variable name of the API key. Defaults to "PLANET_API_KEY". - """ - layer = planet_tile_by_quarter( - year, quarter, name, api_key, token_name, tile_format="folium" - ) - layer.add_to(self) - - def publish( - self, - name="Folium Map", - description="", - source_url="", - tags=None, - source_file=None, - open=True, - formatting=None, - token=None, - **kwargs, - ): - """Publish the map to datapane.com - - Args: - name (str, optional): The document name - can include spaces, caps, symbols, etc., e.g. "Profit & Loss 2020". Defaults to "Folium Map". - description (str, optional): A high-level description for the document, this is displayed in searches and thumbnails. Defaults to ''. - source_url (str, optional): A URL pointing to the source code for the document, e.g. a GitHub repo or a Colab notebook. Defaults to ''. - tags (bool, optional): A list of tags (as strings) used to categorise your document. Defaults to None. - source_file (str, optional): Path of jupyter notebook file to upload. Defaults to None. - open (bool, optional): Whether to open the map. Defaults to True. - formatting (ReportFormatting, optional): Set the basic styling for your report. - token (str, optional): The token to use to datapane to publish the map. See https://docs.datapane.com/tut-getting-started. Defaults to None. - """ - import webbrowser - import warnings - - warnings.filterwarnings("ignore") - try: - import datapane as dp - except Exception: - webbrowser.open_new_tab("https://docs.datapane.com/tut-getting-started") - raise ImportError( - "The datapane Python package is not installed. You need to install and authenticate datapane first." - ) - - if token is None: - try: - _ = dp.ping(verbose=False) - except Exception as e: - if os.environ.get("DP_TOKEN") is not None: - dp.login(token=os.environ.get("DP_TOKEN")) - else: - raise Exception(e) - else: - dp.login(token) - - try: - - dp.Report(dp.Plot(self)).upload( - name=name, - description=description, - source_url=source_url, - tags=tags, - source_file=source_file, - open=open, - formatting=formatting, - ) - - except Exception as e: - raise Exception(e) - - def to_html(self, outfile=None, **kwargs): - """Exports a map as an HTML file. - - Args: - outfile (str, optional): File path to the output HTML. Defaults to None. - - Raises: - ValueError: If it is an invalid HTML file. - - Returns: - str: A string containing the HTML code. - """ - - if self.options["layersControl"]: - self.add_layer_control() - - if outfile is not None: - if not outfile.endswith(".html"): - raise ValueError("The output file extension must be html.") - outfile = os.path.abspath(outfile) - out_dir = os.path.dirname(outfile) - if not os.path.exists(out_dir): - os.makedirs(out_dir) - self.save(outfile, **kwargs) - else: - outfile = os.path.abspath(random_string() + ".html") - self.save(outfile, **kwargs) - out_html = "" - with open(outfile) as f: - lines = f.readlines() - out_html = "".join(lines) - os.remove(outfile) - return out_html - - def to_streamlit( - self, - width=1000, - height=600, - responsive=True, - scrolling=False, - add_layer_control=True, - bidirectional=False, - **kwargs, - ): - """Renders `folium.Figure` or `folium.Map` in a Streamlit app. This method is a static Streamlit Component, meaning, no information is passed back from Leaflet on browser interaction. - - Args: - width (int, optional): Width of the map. Defaults to 1000. - height (int, optional): Height of the map. Defaults to 600. - responsive (bool, optional): Whether to make the map responsive. Defaults to True. - scrolling (bool, optional): Whether to allow the map to scroll. Defaults to False. - add_layer_control (bool, optional): Whether to add the layer control. Defaults to True. - bidirectional (bool, optional): Whether to add bidirectional functionality to the map. The streamlit-folium package is required to use the bidirectional functionality. Defaults to False. - - Raises: - ImportError: If streamlit is not installed. - - Returns: - streamlit.components: components.html object. - """ - - try: - import streamlit as st - import streamlit.components.v1 as components - - if add_layer_control: - self.add_layer_control() - - if bidirectional: - from streamlit_folium import st_folium - - output = st_folium(self, width=width, height=height) - return output - else: - - if responsive: - make_map_responsive = """ - - """ - st.markdown(make_map_responsive, unsafe_allow_html=True) - return components.html( - self.to_html(), width=width, height=height, scrolling=scrolling - ) - - except Exception as e: - raise Exception(e) - - def st_map_center(self, st_component): - """Get the center of the map. - - Args: - st_folium: The streamlit component. - - Returns: - tuple: The center of the map. - """ - - bounds = st_component["bounds"] - west = bounds["_southWest"]["lng"] - south = bounds["_southWest"]["lat"] - east = bounds["_northEast"]["lng"] - north = bounds["_northEast"]["lat"] - return (south + (north - south) / 2, west + (east - west) / 2) - - def st_map_bounds(self, st_component): - """Get the bounds of the map in the format of (miny, minx, maxy, maxx). - - Args: - st_folium: The streamlit component. - - Returns: - tuple: The bounds of the map. - """ - - bounds = st_component["bounds"] - south = bounds["_southWest"]["lat"] - west = bounds["_southWest"]["lng"] - north = bounds["_northEast"]["lat"] - east = bounds["_northEast"]["lng"] - - bounds = [[south, west], [north, east]] - return bounds - - def st_fit_bounds(self): - """Fit the map to the bounds of the map. - - Returns: - folium.Map: The map. - """ - - try: - import streamlit as st - - if "map_bounds" in st.session_state: - - bounds = st.session_state["map_bounds"] - - self.fit_bounds(bounds) - - except Exception as e: - raise Exception(e) - - def st_last_draw(self, st_component): - """Get the last draw feature of the map. - - Args: - st_folium: The streamlit component. - - Returns: - str: The last draw of the map. - """ - - return st_component["last_active_drawing"] - - def st_last_click(self, st_component): - """Get the last click feature of the map. - - Args: - st_folium: The streamlit component. - - Returns: - str: The last click of the map. - """ - - coords = st_component["last_clicked"] - return (coords["lat"], coords["lng"]) - - def st_draw_features(self, st_component): - """Get the draw features of the map. - - Args: - st_folium: The streamlit component. - - Returns: - list: The draw features of the map. - """ - - return st_component["all_drawings"] - - def add_title(self, title, align="center", font_size="16px", style=None): - """Adds a title to the map. - - Args: - title (str): The title to use. - align (str, optional): The alignment of the title, can be ["center", "left", "right"]. Defaults to "center". - font_size (str, optional): The font size in the unit of px. Defaults to "16px". - style ([type], optional): The style to use. Defaults to None. - """ - if style is None: - title_html = """ -

    {}

    - """.format( - align, font_size, title - ) - else: - title_html = """ -

    {}

    - """.format( - align, style, title - ) - self.get_root().html.add_child(folium.Element(title_html)) - - def static_map(self, width=950, height=600, out_file=None, **kwargs): - """Display a folium static map in a Jupyter Notebook. - - Args - m (folium.Map): A folium map. - width (int, optional): Width of the map. Defaults to 950. - height (int, optional): Height of the map. Defaults to 600. - read_only (bool, optional): Whether to hide the side panel to disable map customization. Defaults to False. - out_file (str, optional): Output html file path. Defaults to None. - """ - if isinstance(self, folium.Map): - if out_file is None: - out_file = "./cache/" + "folium_" + random_string(3) + ".html" - out_dir = os.path.abspath(os.path.dirname(out_file)) - if not os.path.exists(out_dir): - os.makedirs(out_dir) - - self.to_html(out_file) - display_html(src=out_file, width=width, height=height) - else: - raise TypeError("The provided map is not a folium map.") - - def add_census_data(self, wms, layer, census_dict=None, **kwargs): - """Adds a census data layer to the map. - - Args: - wms (str): The wms to use. For example, "Current", "ACS 2021", "Census 2020". See the complete list at https://tigerweb.geo.census.gov/tigerwebmain/TIGERweb_wms.html - layer (str): The layer name to add to the map. - census_dict (dict, optional): A dictionary containing census data. Defaults to None. It can be obtained from the get_census_dict() function. - """ - - try: - if census_dict is None: - census_dict = get_census_dict() - - if wms not in census_dict.keys(): - raise ValueError( - f"The provided WMS is invalid. It must be one of {census_dict.keys()}" - ) - - layers = census_dict[wms]["layers"] - if layer not in layers: - raise ValueError( - f"The layer name is not valid. It must be one of {layers}" - ) - - url = census_dict[wms]["url"] - if "name" not in kwargs: - kwargs["name"] = layer - if "attribution" not in kwargs: - kwargs["attribution"] = "U.S. Census Bureau" - if "format" not in kwargs: - kwargs["format"] = "image/png" - if "transparent" not in kwargs: - kwargs["transparent"] = True - - self.add_wms_layer(url, layer, **kwargs) - - except Exception as e: - raise Exception(e) - - def add_xyz_service(self, provider, **kwargs): - """Add a XYZ tile layer to the map. - - Args: - provider (str): A tile layer name starts with xyz or qms. For example, xyz.OpenTopoMap, - - Raises: - ValueError: The provider is not valid. It must start with xyz or qms. - """ - import xyzservices.providers as xyz - from xyzservices import TileProvider - - if provider.startswith("xyz"): - name = provider[4:] - xyz_provider = xyz.flatten()[name] - url = xyz_provider.build_url() - attribution = xyz_provider.attribution - if attribution.strip() == "": - attribution = " " - self.add_tile_layer(url, name, attribution) - elif provider.startswith("qms"): - name = provider[4:] - qms_provider = TileProvider.from_qms(name) - url = qms_provider.build_url() - attribution = qms_provider.attribution - if attribution.strip() == "": - attribution = " " - self.add_tile_layer(url=url, name=name, attribution=attribution) - else: - raise ValueError( - f"The provider {provider} is not valid. It must start with xyz or qms." - ) - - def add_marker( - self, location, popup=None, tooltip=None, icon=None, draggable=False, **kwargs - ): - """Adds a marker to the map. More info about marker options at https://python-visualization.github.io/folium/modules.html#folium.map.Marker. - - Args: - location (list | tuple): The location of the marker in the format of [lat, lng]. - popup (str, optional): The popup text. Defaults to None. - tooltip (str, optional): The tooltip text. Defaults to None. - icon (str, optional): The icon to use. Defaults to None. - draggable (bool, optional): Whether the marker is draggable. Defaults to False. - """ - if isinstance(location, list): - location = tuple(location) - if isinstance(location, tuple): - folium.Marker( - location=location, - popup=popup, - tooltip=tooltip, - icon=icon, - draggable=draggable, - **kwargs, - ).add_to(self) - - else: - raise TypeError("The location must be a list or a tuple.") - - def add_colormap( - self, - cmap="gray", - colors=None, - discrete=False, - label=None, - width=8.0, - height=0.4, - orientation="horizontal", - vmin=0, - vmax=1.0, - axis_off=False, - show_name=False, - font_size=12, - transparent_bg=False, - position="bottomright", - **kwargs, - ): - """Adds a matplotlib colormap to the map.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def add_points_from_xy( - self, - data, - x="longitude", - y="latitude", - popup=None, - min_width=100, - max_width=200, - layer_name="Marker Cluster", - color_column=None, - marker_colors=None, - icon_colors=["white"], - icon_names=["info"], - angle=0, - prefix="fa", - add_legend=True, - **kwargs, - ): - """Adds a marker cluster to the map. - - Args: - data (str | pd.DataFrame): A csv or Pandas DataFrame containing x, y, z values. - x (str, optional): The column name for the x values. Defaults to "longitude". - y (str, optional): The column name for the y values. Defaults to "latitude". - popup (list, optional): A list of column names to be used as the popup. Defaults to None. - min_width (int, optional): The minimum width of the popup. Defaults to 100. - max_width (int, optional): The maximum width of the popup. Defaults to 200. - layer_name (str, optional): The name of the layer. Defaults to "Marker Cluster". - color_column (str, optional): The column name for the color values. Defaults to None. - marker_colors (list, optional): A list of colors to be used for the markers. Defaults to None. - icon_colors (list, optional): A list of colors to be used for the icons. Defaults to ['white']. - icon_names (list, optional): A list of names to be used for the icons. More icons can be found at https://fontawesome.com/v4/icons or https://getbootstrap.com/docs/3.3/components/?utm_source=pocket_mylist. Defaults to ['info']. - angle (int, optional): The angle of the icon. Defaults to 0. - prefix (str, optional): The prefix states the source of the icon. 'fa' for font-awesome or 'glyphicon' for bootstrap 3. Defaults to 'fa'. - add_legend (bool, optional): If True, a legend will be added to the map. Defaults to True. - """ - import pandas as pd - - color_options = [ - "red", - "blue", - "green", - "purple", - "orange", - "darkred", - "lightred", - "beige", - "darkblue", - "darkgreen", - "cadetblue", - "darkpurple", - "white", - "pink", - "lightblue", - "lightgreen", - "gray", - "black", - "lightgray", - ] - - if isinstance(data, pd.DataFrame): - df = data - elif not data.startswith("http") and (not os.path.exists(data)): - raise FileNotFoundError("The specified input csv does not exist.") - else: - df = pd.read_csv(data) - - col_names = df.columns.values.tolist() - - if color_column is not None and color_column not in col_names: - raise ValueError( - f"The color column {color_column} does not exist in the dataframe." - ) - - if color_column is not None: - items = list(set(df[color_column])) - else: - items = None - - if color_column is not None and marker_colors is None: - if len(items) > len(color_options): - raise ValueError( - f"The number of unique values in the color column {color_column} is greater than the number of available colors." - ) - else: - marker_colors = color_options[: len(items)] - elif color_column is not None and marker_colors is not None: - if len(items) != len(marker_colors): - raise ValueError( - f"The number of unique values in the color column {color_column} is not equal to the number of available colors." - ) - - if items is not None: - - if len(icon_colors) == 1: - icon_colors = icon_colors * len(items) - elif len(items) != len(icon_colors): - raise ValueError( - f"The number of unique values in the color column {color_column} is not equal to the number of available colors." - ) - - if len(icon_names) == 1: - icon_names = icon_names * len(items) - elif len(items) != len(icon_names): - raise ValueError( - f"The number of unique values in the color column {color_column} is not equal to the number of available colors." - ) - - if popup is None: - popup = col_names - - if x not in col_names: - raise ValueError(f"x must be one of the following: {', '.join(col_names)}") - - if y not in col_names: - raise ValueError(f"y must be one of the following: {', '.join(col_names)}") - - marker_cluster = plugins.MarkerCluster(name=layer_name).add_to(self) - - for idx, row in df.iterrows(): - html = "" - for p in popup: - html = html + "" + p + "" + ": " + str(row[p]) + "
    " - popup_html = folium.Popup(html, min_width=min_width, max_width=max_width) - - if items is not None: - index = items.index(row[color_column]) - marker_icon = folium.Icon( - color=marker_colors[index], - icon_color=icon_colors[index], - icon=icon_names[index], - angle=angle, - prefix=prefix, - ) - else: - marker_icon = None - - folium.Marker( - location=[row[y], row[x]], - popup=popup_html, - icon=marker_icon, - ).add_to(marker_cluster) - - if items is not None and add_legend: - marker_colors = [c for c in marker_colors] - self.add_legend( - title=color_column.title(), colors=marker_colors, labels=items - ) - - def add_circle_markers_from_xy( - self, - data, - x="longitude", - y="latitude", - radius=10, - popup=None, - tooltip=None, - min_width=100, - max_width=200, - **kwargs, - ): - """Adds a marker cluster to the map. - - Args: - data (str | pd.DataFrame): A csv or Pandas DataFrame containing x, y, z values. - x (str, optional): The column name for the x values. Defaults to "longitude". - y (str, optional): The column name for the y values. Defaults to "latitude". - radius (int, optional): The radius of the circle. Defaults to 10. - popup (list, optional): A list of column names to be used as the popup. Defaults to None. - tooltip (list, optional): A list of column names to be used as the tooltip. Defaults to None. - min_width (int, optional): The minimum width of the popup. Defaults to 100. - max_width (int, optional): The maximum width of the popup. Defaults to 200. - - """ - import pandas as pd - - if isinstance(data, pd.DataFrame): - df = data - elif not data.startswith("http") and (not os.path.exists(data)): - raise FileNotFoundError("The specified input csv does not exist.") - else: - df = pd.read_csv(data) - - col_names = df.columns.values.tolist() - - if "color" not in kwargs: - kwargs["color"] = None - if "fill" not in kwargs: - kwargs["fill"] = True - if "fill_color" not in kwargs: - kwargs["fill_color"] = "blue" - if "fill_opacity" not in kwargs: - kwargs["fill_opacity"] = 0.7 - - if popup is None: - popup = col_names - - if not isinstance(popup, list): - popup = [popup] - - if tooltip is not None: - if not isinstance(tooltip, list): - tooltip = [tooltip] - - if x not in col_names: - raise ValueError(f"x must be one of the following: {', '.join(col_names)}") - - if y not in col_names: - raise ValueError(f"y must be one of the following: {', '.join(col_names)}") - - for idx, row in df.iterrows(): - html = "" - for p in popup: - html = html + "" + p + "" + ": " + str(row[p]) + "
    " - popup_html = folium.Popup(html, min_width=min_width, max_width=max_width) - - if tooltip is not None: - html = "" - for p in tooltip: - html = html + "" + p + "" + ": " + str(row[p]) + "
    " - - tooltip_str = folium.Tooltip(html) - else: - tooltip_str = None - - folium.CircleMarker( - location=[row[y], row[x]], - radius=radius, - popup=popup_html, - tooltip=tooltip_str, - **kwargs, - ).add_to(self) - - def add_labels( - self, - data, - column, - font_size="12pt", - font_color="black", - font_family="arial", - font_weight="normal", - x="longitude", - y="latitude", - draggable=True, - layer_name="Labels", - **kwargs, - ): - """Adds a label layer to the map. Reference: https://python-visualization.github.io/folium/modules.html#folium.features.DivIcon - - Args: - data (pd.DataFrame | gpd.GeoDataFrame | str): The input data to label. - column (str): The column name of the data to label. - font_size (str, optional): The font size of the labels. Defaults to "12pt". - font_color (str, optional): The font color of the labels. Defaults to "black". - font_family (str, optional): The font family of the labels. Defaults to "arial". - font_weight (str, optional): The font weight of the labels, can be normal, bold. Defaults to "normal". - x (str, optional): The column name of the longitude. Defaults to "longitude". - y (str, optional): The column name of the latitude. Defaults to "latitude". - draggable (bool, optional): Whether the labels are draggable. Defaults to True. - layer_name (str, optional): The name of the layer. Defaults to "Labels". - - """ - import warnings - import pandas as pd - from folium.features import DivIcon - - warnings.filterwarnings("ignore") - - if isinstance(data, pd.DataFrame): - df = data - if "geometry" in data.columns or ("geom" in data.columns): - df[x] = df.centroid.x - df[y] = df.centroid.y - elif isinstance(data, str): - ext = os.path.splitext(data)[1] - if ext == ".csv": - df = pd.read_csv(data) - elif ext in [".geojson", ".json", ".shp", ".gpkg"]: - try: - import geopandas as gpd - - df = gpd.read_file(data) - df[x] = df.centroid.x - df[y] = df.centroid.y - except ImportError: - print("geopandas is required to read geojson.") - return - else: - raise ValueError("data must be a DataFrame or an ee.FeatureCollection.") - - if column not in df.columns: - raise ValueError(f"column must be one of {', '.join(df.columns)}.") - if x not in df.columns: - raise ValueError(f"column must be one of {', '.join(df.columns)}.") - if y not in df.columns: - raise ValueError(f"column must be one of {', '.join(df.columns)}.") - - try: - size = int(font_size.replace("pt", "")) - except: - raise ValueError("font_size must be something like '10pt'") - - layer_group = folium.FeatureGroup(name=layer_name) - for index in df.index: - html = f'
    {df[column][index]}
    ' - folium.Marker( - location=[df[y][index], df[x][index]], - icon=DivIcon( - icon_size=(1, 1), - icon_anchor=(size, size), - html=html, - **kwargs, - ), - draggable=draggable, - ).add_to(layer_group) - - layer_group.add_to(self) - - def split_map(self, left_layer="TERRAIN", right_layer="OpenTopoMap", **kwargs): - """Adds a split-panel map. - - Args: - left_layer (str, optional): The layer tile layer. Defaults to 'TERRAIN'. - right_layer (str, optional): The right tile layer. Defaults to 'OpenTopoMap'. - """ - try: - if left_layer in basemaps.keys(): - left_layer = basemaps[left_layer] - elif isinstance(left_layer, str): - if left_layer.startswith("http") and left_layer.endswith(".tif"): - url = cog_tile(left_layer) - left_layer = folium.raster_layers.TileLayer( - tiles=url, - name="Left Layer", - attr=" ", - overlay=True, - ) - else: - left_layer = folium.raster_layers.TileLayer( - tiles=left_layer, - name="Left Layer", - attr=" ", - overlay=True, - ) - elif isinstance(left_layer, folium.raster_layers.TileLayer) or isinstance( - left_layer, folium.WmsTileLayer - ): - pass - else: - raise ValueError( - f"left_layer must be one of the following: {', '.join(basemaps.keys())} or a string url to a tif file." - ) - - if right_layer in basemaps.keys(): - right_layer = basemaps[right_layer] - elif isinstance(right_layer, str): - if right_layer.startswith("http") and right_layer.endswith(".tif"): - url = cog_tile(right_layer) - right_layer = folium.raster_layers.TileLayer( - tiles=url, - name="Right Layer", - attr=" ", - overlay=True, - ) - else: - right_layer = folium.raster_layers.TileLayer( - tiles=right_layer, - name="Right Layer", - attr=" ", - overlay=True, - ) - elif isinstance(right_layer, folium.raster_layers.TileLayer) or isinstance( - left_layer, folium.WmsTileLayer - ): - pass - else: - raise ValueError( - f"right_layer must be one of the following: {', '.join(basemaps.keys())} or a string url to a tif file." - ) - - control = SplitControl( - layer_left=left_layer, layer_right=right_layer, name="Split Control" - ) - left_layer.add_to(self) - right_layer.add_to(self) - control.add_to(self) - - except Exception as e: - print("The provided layers are invalid!") - raise ValueError(e) - - def add_data( - self, - data, - column, - colors=None, - labels=None, - cmap=None, - scheme="Quantiles", - k=5, - add_legend=True, - legend_title=None, - legend_kwds=None, - classification_kwds=None, - style_function=None, - highlight_function=None, - layer_name="Untitled", - info_mode="on_hover", - encoding="utf-8", - **kwargs, - ): - """Add vector data to the map with a variety of classification schemes. - - Args: - data (str | pd.DataFrame | gpd.GeoDataFrame): The data to classify. It can be a filepath to a vector dataset, a pandas dataframe, or a geopandas geodataframe. - column (str): The column to classify. - cmap (str, optional): The name of a colormap recognized by matplotlib. Defaults to None. - colors (list, optional): A list of colors to use for the classification. Defaults to None. - labels (list, optional): A list of labels to use for the legend. Defaults to None. - scheme (str, optional): Name of a choropleth classification scheme (requires mapclassify). - 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. - k (int, optional): Number of classes (ignored if scheme is None or if column is categorical). Default to 5. - legend_kwds (dict, optional): Keyword arguments to pass to :func:`matplotlib.pyplot.legend` or `matplotlib.pyplot.colorbar`. Defaults to None. - Keyword arguments to pass to :func:`matplotlib.pyplot.legend` or - Additional accepted keywords when `scheme` is specified: - fmt : string - A formatting specification for the bin edges of the classes in the - legend. For example, to have no decimals: ``{"fmt": "{:.0f}"}``. - labels : list-like - A list of legend labels to override the auto-generated labblels. - Needs to have the same number of elements as the number of - classes (`k`). - interval : boolean (default False) - An option to control brackets from mapclassify legend. - If True, open/closed interval brackets are shown in the legend. - classification_kwds (dict, optional): Keyword arguments to pass to mapclassify. Defaults to None. - layer_name (str, optional): The layer name to be used.. Defaults to "Untitled". - style_function (function, optional): Styling function that is called for each feature, and should return the feature style. This styling function takes the feature as argument. Defaults to None. - style_callback is a function that takes the feature as argument and should return a dictionary of the following form: - style_callback = lambda feat: {"fillColor": feat["properties"]["color"]} - style is a dictionary of the following form: - style = { - "stroke": False, - "color": "#ff0000", - "weight": 1, - "opacity": 1, - "fill": True, - "fillColor": "#ffffff", - "fillOpacity": 1.0, - "dashArray": "9" - "clickable": True, - } - hightlight_function (function, optional): Highlighting function that is called for each feature, and should return the feature style. This styling function takes the feature as argument. Defaults to None. - highlight_function is a function that takes the feature as argument and should return a dictionary of the following form: - highlight_function = lambda feat: {"fillColor": feat["properties"]["color"]} - info_mode (str, optional): Displays the attributes by either on_hover or on_click. Any value other than "on_hover" or "on_click" will be treated as None. Defaults to "on_hover". - encoding (str, optional): The encoding of the GeoJSON file. Defaults to "utf-8". - """ - - import warnings - - gdf, legend_dict = classify( - data=data, - column=column, - cmap=cmap, - colors=colors, - labels=labels, - scheme=scheme, - k=k, - legend_kwds=legend_kwds, - classification_kwds=classification_kwds, - ) - - if legend_title is None: - legend_title = column - - if "style" in kwargs: - warnings.warn( - "The style arguments is for ipyleaflet only. ", - UserWarning, - ) - kwargs.pop("style") - - if "hover_style" in kwargs: - warnings.warn( - "The hover_style arguments is for ipyleaflet only. ", - UserWarning, - ) - kwargs.pop("hover_style") - - if "style_callback" in kwargs: - warnings.warn( - "The style_callback arguments is for ipyleaflet only. ", - UserWarning, - ) - kwargs.pop("style_callback") - - if style_function is None: - style_function = lambda feat: { - # "stroke": False, - # "color": "#ff0000", - "weight": 1, - "opacity": 1, - # "fill": True, - # "fillColor": "#ffffff", - "fillOpacity": 1.0, - # "dashArray": "9" - # "clickable": True, - "fillColor": feat["properties"]["color"], - } - - if highlight_function is None: - highlight_function = lambda feat: { - "weight": 2, - "fillOpacity": 0.5, - } - - self.add_gdf( - gdf, - layer_name=layer_name, - style_function=style_function, - highlight_function=highlight_function, - info_mode=info_mode, - encoding=encoding, - **kwargs, - ) - if add_legend: - self.add_legend(title=legend_title, legend_dict=legend_dict) - - def remove_labels(self, **kwargs): - """Removes a layer from the map.""" - print("The folium plotting backend does not support removing labels.") - - def add_minimap(self, zoom=5, position="bottomright"): - """Adds a minimap (overview) to the ipyleaflet map.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def add_point_layer( - self, filename, popup=None, layer_name="Marker Cluster", **kwargs - ): - """Adds a point layer to the map with a popup attribute.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def add_raster( - self, - image, - bands=None, - layer_name=None, - colormap=None, - x_dim="x", - y_dim="y", - ): - """Adds a local raster dataset to the map.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def add_time_slider( - self, - layers_dict={}, - labels=None, - time_interval=1, - position="bottomright", - slider_length="150px", - ): - """Adds a time slider to the map.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def add_vector_tile_layer( - self, - url="https://tile.nextzen.org/tilezen/vector/v1/512/all/{z}/{x}/{y}.mvt?api_key=gCZXZglvRQa6sB2z7JzL1w", - attribution="", - vector_tile_layer_styles=dict(), - **kwargs, - ): - """Adds a VectorTileLayer to the map.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def add_xy_data( - self, - in_csv, - x="longitude", - y="latitude", - label=None, - layer_name="Marker cluster", - ): - """Adds points from a CSV file containing lat/lon information and display data on the map.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def basemap_demo(self): - """A demo for using leafmap basemaps.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def find_layer(self, name): - """Finds layer by name.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def find_layer_index(self, name): - """Finds layer index by name.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def get_layer_names(self): - """Gets layer names as a list.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def get_scale(self): - """Returns the approximate pixel scale of the current map view, in meters.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def image_overlay(self, url, bounds, name): - """Overlays an image from the Internet or locally on the map. - - Args: - url (str): http URL or local file path to the image. - bounds (tuple): bounding box of the image in the format of (lower_left(lat, lon), upper_right(lat, lon)), such as ((13, -130), (32, -100)). - name (str): name of the layer to show on the layer control. - """ - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def layer_opacity(self, name, value=1.0): - """Changes layer opacity.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def to_image(self, outfile=None, monitor=1): - """Saves the map as a PNG or JPG image.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def toolbar_reset(self): - """Reset the toolbar so that no tool is selected.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def video_overlay(self, url, bounds, name): - """Overlays a video from the Internet on the map.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - def add_search_control( - self, url, marker=None, zoom=None, position="topleft", **kwargs - ): - """Adds a search control to the map.""" - print("The folium plotting backend does not support this function.") - - def save_draw_features(self, out_file, indent=4, **kwargs): - """Save the draw features to a file. - - Args: - out_file (str): The output file path. - indent (int, optional): The indentation level when saving data as a GeoJSON. Defaults to 4. - """ - print("The folium plotting backend does not support this function.") - - def edit_vector(self, data, **kwargs): - """Edit a vector layer. - - Args: - data (dict | str): The data to edit. It can be a GeoJSON dictionary or a file path. - """ - print("The folium plotting backend does not support this function.") - - def add_velocity( - self, - data, - zonal_speed, - meridional_speed, - latitude_dimension="lat", - longitude_dimension="lon", - velocity_scale=0.01, - max_velocity=20, - display_options={}, - name="Velocity", - ): - - print(f"The folium plotting backend does not support this function.") - - -class SplitControl(Layer): - """ - Creates a SplitControl that takes two Layers and adds a sliding control with the leaflet-side-by-side plugin. - Uses the Leaflet leaflet-side-by-side plugin https://github.com/digidem/leaflet-side-by-side Parameters. - The source code is adapted from https://github.com/python-visualization/folium/pull/1292 - ---------- - layer_left: Layer. - The left Layer within the side by side control. - Must be created and added to the map before being passed to this class. - layer_right: Layer. - The left Layer within the side by side control. - Must be created and added to the map before being passed to this class. - name : string, default None - The name of the Layer, as it will appear in LayerControls. - overlay : bool, default True - Adds the layer as an optional overlay (True) or the base layer (False). - control : bool, default True - Whether the Layer will be included in LayerControls. - show: bool, default True - Whether the layer will be shown on opening (only for overlays). - Examples - -------- - >>> sidebyside = SideBySideLayers(layer_left, layer_right) - >>> sidebyside.add_to(m) - """ - - _template = Template( - """ - {% macro script(this, kwargs) %} - var {{ this.get_name() }} = L.control.sideBySide( - {{ this.layer_left.get_name() }}, {{ this.layer_right.get_name() }} - ).addTo({{ this._parent.get_name() }}); - {% endmacro %} - """ - ) - - def __init__( - self, layer_left, layer_right, name=None, overlay=True, control=True, show=True - ): - super(SplitControl, self).__init__( - name=name, overlay=overlay, control=control, show=show - ) - self._name = "SplitControl" - self.layer_left = layer_left - self.layer_right = layer_right - - def render(self, **kwargs): - super(SplitControl, self).render() - - figure = self.get_root() - assert isinstance(figure, Figure), ( - "You cannot render this Element " "if it is not in a Figure." - ) - - figure.header.add_child( - JavascriptLink( - "https://raw.githack.com/digidem/leaflet-side-by-side/gh-pages/leaflet-side-by-side.js" - ), # noqa - name="leaflet.sidebyside", - ) - - -def delete_dp_report(name): - """Deletes a datapane report. - - Args: - name (str): Name of the report to delete. - """ - try: - import datapane as dp - - reports = dp.Report.list() - items = list(reports) - names = list(map(lambda item: item["name"], items)) - if name in names: - report = dp.Report.get(name) - url = report.blocks[0]["url"] - # print('Deleting {}...'.format(url)) - dp.Report.delete(dp.Report.by_id(url)) - except Exception as e: - raise Exception(e) - - -def delete_dp_reports(): - """Deletes all datapane reports.""" - try: - import datapane as dp - - reports = dp.Report.list() - for item in reports: - print(item["name"]) - report = dp.Report.get(item["name"]) - url = report.blocks[0]["url"] - print("Deleting {}...".format(url)) - dp.Report.delete(dp.Report.by_id(url)) - except Exception as e: - raise Exception(e) - - -def linked_maps( - rows=2, - cols=2, - height="400px", - layers=[], - labels=[], - label_position="topright", - **kwargs, -): - """Create linked maps of XYZ tile layers.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - -def split_map( - left_layer="ROADMAP", - right_layer="HYBRID", - left_label=None, - right_label=None, - label_position="bottom", - **kwargs, -): - """Creates a split-panel map.""" - raise NotImplementedError( - "The folium plotting backend does not support this function. Use the ipyleaflet plotting backend instead." - ) - - -def st_map_center(lat, lon): - """Returns the map center coordinates for a given latitude and longitude. If the system variable 'map_center' exists, it is used. Otherwise, the default is returned. - - Args: - lat (float): Latitude. - lon (float): Longitude. - - Raises: - Exception: If streamlit is not installed. - - Returns: - list: The map center coordinates. - """ - try: - import streamlit as st - - if "map_center" in st.session_state: - return st.session_state["map_center"] - else: - return [lat, lon] - - except Exception as e: - raise Exception(e) - - -def st_save_bounds(st_component): - """Saves the map bounds to the session state. - - Args: - map (folium.folium.Map): The map to save the bounds from. - """ - try: - import streamlit as st - - if st_component is not None: - bounds = st_component["bounds"] - south = bounds["_southWest"]["lat"] - west = bounds["_southWest"]["lng"] - north = bounds["_northEast"]["lat"] - east = bounds["_northEast"]["lng"] - - bounds = [[south, west], [north, east]] - center = [south + (north - south) / 2, west + (east - west) / 2] - - st.session_state["map_bounds"] = bounds - st.session_state["map_center"] = center - except Exception as e: - raise Exception(e) diff --git a/ecoscope/contrib/legend.txt b/ecoscope/contrib/legend.txt deleted file mode 100644 index 8e6c9376..00000000 --- a/ecoscope/contrib/legend.txt +++ /dev/null @@ -1,102 +0,0 @@ -""" -{% macro html(this, kwargs) %} - - - - - - - jQuery UI Draggable - Default functionality - - - - - - - - - - -
    - -
    Legend
    -
    -
      -
    • Big
    • -
    • Medium
    • -
    • Small
    • -
    -
    -
    - - - - - -{% endmacro %}""" From 119579ac2de2275a08fa39739b107df0387c397b Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Wed, 26 Jun 2024 00:35:10 +1000 Subject: [PATCH 20/28] support applying cmap to a column in add_gdf() --- ecoscope/mapping/map.py | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/ecoscope/mapping/map.py b/ecoscope/mapping/map.py index 63ec35b8..ad9e3def 100644 --- a/ecoscope/mapping/map.py +++ b/ecoscope/mapping/map.py @@ -14,7 +14,7 @@ from lonboard._viewport import compute_view, bbox_to_zoom_level from lonboard._layer import BaseLayer, BitmapLayer, BitmapTileLayer from lonboard._viz import viz_layer -from lonboard.colormap import apply_categorical_cmap +from lonboard.colormap import apply_categorical_cmap, apply_continuous_cmap from lonboard._deck_widget import ( BaseDeckWidget, NorthArrowWidget, @@ -108,12 +108,23 @@ def add_widget(self, widget: BaseDeckWidget): update.append(widget) self.deck_widgets = update - def add_gdf(self, data: Union[gpd.GeoDataFrame, gpd.GeoSeries], zoom: bool = True, **kwargs): + def add_gdf( + self, + data: Union[gpd.GeoDataFrame, gpd.GeoSeries], + column: str = None, + cmap: Union[str, mpl.colors.Colormap] = None, + zoom: bool = True, + **kwargs + ): """ Visualize a gdf on the map, results in one or more layers being added Parameters ---------- data : lonboard.BaseDeckWidget or list[lonboard.BaseDeckWidget] + column : str + a column in the dataframe to apply a cmap to + cmap : str or mpl.colors.Colormap + a colormap to apply to the named column zoom : bool Whether or not to zoom the map to the bounds of the data kwargs: @@ -123,13 +134,34 @@ def add_gdf(self, data: Union[gpd.GeoDataFrame, gpd.GeoSeries], zoom: bool = Tru data = data.to_crs(4326) data = data.loc[(~data.geometry.isna()) & (~data.geometry.is_empty)] + polygon_kwargs = scatterplot_kwargs = path_kwargs = {} + if isinstance(data, gpd.GeoDataFrame): for col in data: if pd.api.types.is_datetime64_any_dtype(data[col]): data[col] = data[col].astype("string") - self.add_layer(viz_layer(data=data, **kwargs)) + if column is not None and cmap is not None: + col = data[column] + normalized = (col - col.min()) / (col.max() - col.min()) + if isinstance(cmap, str): + cmap = mpl.colormaps[cmap] + + colormap = apply_continuous_cmap(normalized, cmap) + + polygon_kwargs = scatterplot_kwargs = {"get_fill_color": colormap} + path_kwargs = {"get_color": colormap} + + self.add_layer( + viz_layer( + data=data, + polygon_kwargs=polygon_kwargs, + scatterplot_kwargs=scatterplot_kwargs, + path_kwargs=path_kwargs, + **kwargs + ) + ) if zoom: self.zoom_to_bounds(data) From 838ed6a25c5f404ef437ad1a6a938aadabb474f1 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Wed, 26 Jun 2024 01:38:09 +1000 Subject: [PATCH 21/28] notebook updates --- ecoscope/mapping/map.py | 2 +- .../Elliptical Time Density (ETD).ipynb | 4 +- notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb | 65 +++++++------------ 3 files changed, 27 insertions(+), 44 deletions(-) diff --git a/ecoscope/mapping/map.py b/ecoscope/mapping/map.py index ad9e3def..8f179b41 100644 --- a/ecoscope/mapping/map.py +++ b/ecoscope/mapping/map.py @@ -120,7 +120,7 @@ def add_gdf( Visualize a gdf on the map, results in one or more layers being added Parameters ---------- - data : lonboard.BaseDeckWidget or list[lonboard.BaseDeckWidget] + gdf : gpd.GeoDataFrame or gpd.GeoSeries column : str a column in the dataframe to apply a cmap to cmap : str or mpl.colors.Colormap diff --git a/notebooks/03. Home Range & Movescape/Elliptical Time Density (ETD).ipynb b/notebooks/03. Home Range & Movescape/Elliptical Time Density (ETD).ipynb index 5156691d..7a088bb0 100644 --- a/notebooks/03. Home Range & Movescape/Elliptical Time Density (ETD).ipynb +++ b/notebooks/03. Home Range & Movescape/Elliptical Time Density (ETD).ipynb @@ -311,8 +311,8 @@ "outputs": [], "source": [ "m = EcoMap(width=800, height=600)\n", - "m.add_gdf(salif, column=\"percentile\", cmap=\"RdYlGn\")\n", - "m.zoom_to_gdf(salif.geometry)\n", + "\n", + "m.add_gdf(salif, column=\"percentile\", cmap=\"RdYlGn\", zoom=True)\n", "m" ] } diff --git a/notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb b/notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb index bc2ad5e8..37b83be2 100644 --- a/notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb +++ b/notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb @@ -246,16 +246,11 @@ "outputs": [], "source": [ "# Initialize EcoMap by setting the zoom level and center\n", - "m = EcoMap(center=(0.0236, 37.9062), zoom=6, height=800, width=1000, static=False)\n", + "m = EcoMap(height=800, width=1000, static=False)\n", + "m.set_view_state(latitude=0.0236, longitude=37.9062, zoom=6)\n", "\n", - "# Add two tiled basemaps (OSM and Google satellite)\n", - "m.add_basemap(\"OpenStreetMap\")\n", - "m.add_tile_layer(\n", - " url=\"https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}\",\n", - " name=\"Google Satellite\",\n", - " attribution=\"Google\",\n", - " opacity=0.5,\n", - ")\n", + "# Add tiled base layer\n", + "m.add_layer(EcoMap.get_named_tile_layer(\"OpenStreetMap\"))\n", "\n", "# Display\n", "m" @@ -276,16 +271,15 @@ "source": [ "m = EcoMap(width=800, height=600)\n", "\n", - "# Add two tiled basemaps (OSM and Google Satellite Hybrid)\n", + "# Add tiled base layer\n", "m.add_basemap(\"OpenStreetMap\")\n", - "m.add_basemap(\"HYBRID\")\n", "\n", "# Set DEM visualization parameters\n", "vis_params = {\"min\": 0, \"max\": 4000, \"opacity\": 0.5, \"palette\": [\"006633\", \"E5FFCC\", \"662A00\", \"D8D8D8\", \"F5F5F5\"]}\n", "\n", "# Add Google Earth Engine elevation layer\n", "dem = ee.Image(\"USGS/SRTMGL1_003\")\n", - "m.add_ee_layer(dem.updateMask(dem.gt(0)), vis_params, \"DEM\")\n", + "m.add_ee_layer(dem.updateMask(dem.gt(0)), vis_params)\n", "\n", "# Zoom in and add regions outlines\n", "m.zoom_to_gdf(region_gdf)\n", @@ -307,16 +301,13 @@ ")\n", "\n", "# Add title\n", - "m.add_title(title=\"Elephant Sighting Map\", align=\"center\", font_size=\"18px\")\n", + "m.add_title(\"Elephant Sighting Map\")\n", "\n", - "# Add north-arrow. Positions are: topright, topleft, bottomright, bottomleft\n", - "m.add_north_arrow(position=\"topright\", scale=1, angle=0)\n", + "# Add north-arrow. Placements are: top-right, top-left, bottom-right, bottom-left\n", + "m.add_north_arrow(placement=\"top-left\")\n", "\n", "# Add legend\n", - "m.add_legend(\n", - " legend_dict={\"KDB025Z_Tracks\": \"468af7\", \"Elephant_Sighting_Events\": \"f746ad\"},\n", - " box_position={\"bottom\": \"20px\", \"right\": \"20px\"},\n", - ")\n", + "m.add_legend(labels=[\"KDB025Z_Tracks\", \"Elephant_Sighting_Events\"], colors=[\"#468af7\", \"#f746ad\"])\n", "\n", "# Display\n", "m" @@ -341,7 +332,7 @@ ")\n", "\n", "m = EcoMap(width=800, height=600)\n", - "m.add_local_geotiff(path=os.path.join(output_dir, \"mara_dem.tif\"), zoom=True, cmap=\"jet\", colorbar=True)\n", + "m.add_local_geotiff(path=os.path.join(output_dir, \"mara_dem.tif\"), zoom=True, cmap=\"jet\")\n", "m" ] }, @@ -371,16 +362,12 @@ "source": [ "colors = [\"#292965\" if is_night else \"#e7a553\" for is_night in movebank_relocations_gdf.is_night]\n", "\n", - "m = movebank_relocations_gdf[[\"groupby_col\", \"fixtime\", \"geometry\", \"is_night\"]].explore(\n", - " m=EcoMap(width=800, height=600), color=colors\n", - ")\n", - "m.zoom_to_gdf(movebank_relocations_gdf)\n", + "m = movebank_relocations_gdf[[\"groupby_col\", \"fixtime\", \"geometry\", \"is_night\"]].explore()\n", + "m.zoom_to_bounds(movebank_relocations_gdf)\n", "\n", - "m.add_legend(\n", - " title=\"Is Night\", legend_dict={True: \"292965\", False: \"e7a553\"}, box_position={\"bottom\": \"20px\", \"right\": \"20px\"}\n", - ")\n", - "m.add_north_arrow(position=\"topright\", scale=1, angle=0)\n", - "m.add_title(title=\"Day-Night Relocation Map\", align=\"center\", font_size=\"18px\")\n", + "m.add_legend(title=\"Is Night\", labels=[\"True\", \"False\"], colors=[\"#292965\", \"#e7a553\"])\n", + "m.add_north_arrow(placement=\"top-left\")\n", + "m.add_title(\"Day-Night Relocation Map\")\n", "\n", "m" ] @@ -418,16 +405,12 @@ "source": [ "colors = [\"#292965\" if is_night else \"#e7a553\" for is_night in movebank_trajectory_gdf.extra__is_night]\n", "\n", - "m = movebank_trajectory_gdf[[\"groupby_col\", \"segment_start\", \"segment_end\", \"geometry\", \"extra__is_night\"]].explore(\n", - " color=colors, m=EcoMap(width=800, height=600)\n", - ")\n", + "m = movebank_trajectory_gdf[[\"groupby_col\", \"segment_start\", \"segment_end\", \"geometry\", \"extra__is_night\"]].explore()\n", "m.zoom_to_gdf(movebank_trajectory_gdf)\n", "\n", - "m.add_legend(\n", - " title=\"Is Night\", legend_dict={True: \"292965\", False: \"e7a553\"}, box_position={\"bottom\": \"20px\", \"right\": \"20px\"}\n", - ")\n", - "m.add_north_arrow(position=\"topright\", scale=1, angle=0)\n", - "m.add_title(title=\"Day-Night Trajectory Map\", align=\"center\", font_size=\"18px\")\n", + "m.add_legend(title=\"Is Night\", labels=[\"True\", \"False\"], colors=[\"#292965\", \"#e7a553\"])\n", + "m.add_north_arrow(placement=\"top-left\")\n", + "m.add_title(\"Day-Night Trajectory Map\")\n", "\n", "m" ] @@ -447,8 +430,8 @@ "source": [ "m = EcoMap()\n", "m.add_speedmap(trajectory=movebank_trajectory_gdf, classification_method=\"equal_interval\", num_classes=6, bins=None)\n", - "m.add_north_arrow(position=\"topright\", scale=1, angle=0)\n", - "m.add_title(title=\"Elephant Speed Map\", align=\"center\", font_size=\"18px\")\n", + "m.add_north_arrow(placement=\"top-left\")\n", + "m.add_title(\"Elephant Speed Map\")\n", "m.zoom_to_gdf(movebank_trajectory_gdf)\n", "\n", "m" @@ -511,8 +494,8 @@ "m.add_gdf(percentile_areas, column=\"percentile\", cmap=\"RdYlGn\")\n", "m.zoom_to_gdf(percentile_areas)\n", "\n", - "m.add_title(title=\"Salif ETD Range\", align=\"center\", font_size=\"18px\")\n", - "m.add_north_arrow(position=\"topleft\", scale=1, angle=0)\n", + "m.add_title(\"Salif ETD Range\")\n", + "m.add_north_arrow(placement=\"top-left\")\n", "\n", "m" ] From cf173688e24f16d809c358a011c550e4d73877f4 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Wed, 26 Jun 2024 02:51:07 +1000 Subject: [PATCH 22/28] fix environment.yml --- environment.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/environment.yml b/environment.yml index 47be1fee..6d6876a6 100644 --- a/environment.yml +++ b/environment.yml @@ -14,6 +14,7 @@ dependencies: - pre-commit>=3 - coverage[toml] - ecoscope + - lonboard @ git+https://github.com/wildlife-dynamics/lonboard@77c56d30a9c2dd96fd863e910bf62952cfa36da8 - nbsphinx - nbsphinx-multilink - sphinx-autoapi From 910a90d00441027b03b393f60a141435b3c00c3a Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Thu, 27 Jun 2024 20:15:53 +1000 Subject: [PATCH 23/28] remove explore wrapper - effectively reverts this functionality to native folium --- ecoscope/__init__.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/ecoscope/__init__.py b/ecoscope/__init__.py index e3b6e007..b46d7391 100644 --- a/ecoscope/__init__.py +++ b/ecoscope/__init__.py @@ -51,24 +51,6 @@ def init(silent=False, selenium=False, force=False): warnings.filterwarnings(action="ignore", category=FutureWarning) warnings.filterwarnings("ignore", message=".*initial implementation of Parquet.*") - import geopandas as gpd - - def explore(data, *args, **kwargs): - """ - Monkey-patched `geopandas.explore._explore` to use EcoMap instead. - """ - from ecoscope import mapping - - try: - m = mapping.EcoMap2() - m.add_gdf(data, *args, **kwargs) - return m - except Exception: - return gpd.explore._explore(data, *args, **kwargs) - - gpd.GeoDataFrame.explore = explore - gpd.GeoSeries.explore = explore - import plotly.io as pio pio.templates.default = "seaborn" From c2bf05e2554f656f284b716ce3c3348ada647f8e Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Thu, 27 Jun 2024 20:28:41 +1000 Subject: [PATCH 24/28] remove selenium and bs4 deps --- README.rst | 2 +- ecoscope/__init__.py | 56 +------------------------------------------- environment.yml | 1 - setup.py | 1 - 4 files changed, 2 insertions(+), 58 deletions(-) diff --git a/README.rst b/README.rst index ded5ae5b..c774ecc1 100644 --- a/README.rst +++ b/README.rst @@ -29,7 +29,7 @@ Ecoscope is an open-source analysis module for tracking, environmental and conse Development & Testing ===================== -Development dependencies are included in `environment.yml`. You will also need to configure the selenium driver following these instructions (note that this is not needed if running ecoscope within Google Colab as selenium is already configured): https://www.selenium.dev/documentation/webdriver/getting_started/install_drivers/ +Development dependencies are included in `environment.yml`. Please configure code-quality git hooks with: diff --git a/ecoscope/__init__.py b/ecoscope/__init__.py index b46d7391..17190665 100644 --- a/ecoscope/__init__.py +++ b/ecoscope/__init__.py @@ -11,7 +11,7 @@ __initialized = False -def init(silent=False, selenium=False, force=False): +def init(silent=False, force=False): """ Initializes the environment with ecoscope-specific customizations. @@ -19,8 +19,6 @@ def init(silent=False, selenium=False, force=False): ---------- silent : bool, optional Removes console output - selenium : bool, optional - Installs selenium webdriver in a colab environment force : bool, optional Ignores `__initialized` @@ -55,58 +53,6 @@ def init(silent=False, selenium=False, force=False): pio.templates.default = "seaborn" - import sys - - if "google.colab" in sys.modules and selenium: - from IPython import get_ipython - - shell_text = """\ -cat > /etc/apt/sources.list.d/debian.list <<'EOF' -deb [arch=amd64 signed-by=/usr/share/keyrings/debian-bookworm.gpg] http://deb.debian.org/debian bookworm main -deb [arch=amd64 signed-by=/usr/share/keyrings/debian-bookworm-updates.gpg]\ - http://deb.debian.org/debian bookworm-updates main -deb [arch=amd64 signed-by=/usr/share/keyrings/debian-security-bookworm.gpg]\ - http://deb.debian.org/debian-security bookworm/updates main -EOF - -apt-key adv --keyserver keyserver.ubuntu.com --recv-keys DCC9EFBF77E11517 -apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 648ACFD622F3D138 -apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 112695A0E562B32A - -apt-key export 77E11517 | gpg --dearmour -o /usr/share/keyrings/debian-bookworm.gpg -apt-key export 22F3D138 | gpg --dearmour -o /usr/share/keyrings/debian-bookworm-updates.gpg -apt-key export E562B32A | gpg --dearmour -o /usr/share/keyrings/debian-security-bookworm.gpg - -cat > /etc/apt/preferences.d/chromium.pref << 'EOF' -Package: * -Pin: release a=eoan -Pin-Priority: 500 - - -Package: * -Pin: origin "deb.debian.org" -Pin-Priority: 300 - - -Package: chromium* -Pin: origin "deb.debian.org" -Pin-Priority: 700 -EOF - -apt-get update -apt-get install chromium chromium-driver - -pip install selenium -""" - - if silent: - from IPython.utils import io - - with io.capture_output(): - get_ipython().run_cell_magic("shell", "", shell_text) - else: - get_ipython().run_cell_magic("shell", "", shell_text) - __initialized = True if not silent: print(ASCII) diff --git a/environment.yml b/environment.yml index 6d6876a6..f88f181a 100644 --- a/environment.yml +++ b/environment.yml @@ -7,7 +7,6 @@ dependencies: - git - jupyterlab - geopandas<=0.14.2 - - beautifulsoup4 - ipywidgets - pip: - kaleido diff --git a/setup.py b/setup.py index 58c6e664..689303ba 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,6 @@ "scipy", "scikit-image", "scikit-learn", - "selenium", "tqdm", "xyzservices", ] From 1003a2f93e2218330662652a1da64a83ac205718 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Fri, 28 Jun 2024 21:37:17 +1000 Subject: [PATCH 25/28] EcoMap notebook updates --- ecoscope/mapping/map.py | 23 ++++++- notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb | 70 ++++++++++++--------- 2 files changed, 64 insertions(+), 29 deletions(-) diff --git a/ecoscope/mapping/map.py b/ecoscope/mapping/map.py index 8f179b41..c77a3750 100644 --- a/ecoscope/mapping/map.py +++ b/ecoscope/mapping/map.py @@ -12,9 +12,16 @@ from lonboard import Map from lonboard._geoarrow.ops.bbox import Bbox from lonboard._viewport import compute_view, bbox_to_zoom_level -from lonboard._layer import BaseLayer, BitmapLayer, BitmapTileLayer from lonboard._viz import viz_layer from lonboard.colormap import apply_categorical_cmap, apply_continuous_cmap +from lonboard._layer import ( + BaseLayer, + BitmapLayer, + BitmapTileLayer, + PathLayer, + PolygonLayer, + ScatterplotLayer, +) from lonboard._deck_widget import ( BaseDeckWidget, NorthArrowWidget, @@ -165,6 +172,15 @@ def add_gdf( if zoom: self.zoom_to_bounds(data) + def add_path_layer(self, gdf: gpd.GeoDataFrame, zoom: bool = False, **kwargs): + self.add_layer(PathLayer.from_geopandas(gdf, **kwargs), zoom) + + def add_polygon_layer(self, gdf: gpd.GeoDataFrame, zoom: bool = False, **kwargs): + self.add_layer(PolygonLayer.from_geopandas(gdf, **kwargs), zoom) + + def add_scatterplot_layer(self, gdf: gpd.GeoDataFrame, zoom: bool = False, **kwargs): + self.add_layer(ScatterplotLayer.from_geopandas(gdf, **kwargs), zoom) + def add_legend(self, **kwargs): """ Adds a legend to the map @@ -460,3 +476,8 @@ def get_named_tile_layer(layer: str) -> BitmapTileLayer: min_zoom=layer.get("min_zoom", None), max_requests=layer.get("max_requests", None), ) + + @staticmethod + def hex_to_rgb(hex: str) -> list: + hex = hex.strip("#") + return list(int(hex[i : i + 2], 16) for i in (0, 2, 4)) diff --git a/notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb b/notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb index 37b83be2..c8b4287b 100644 --- a/notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb +++ b/notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb @@ -51,6 +51,7 @@ "import geopandas as gpd\n", "import numpy as np\n", "import pandas as pd\n", + "import matplotlib as mpl\n", "import shapely\n", "\n", "import ecoscope\n", @@ -59,8 +60,9 @@ "from ecoscope.analysis.UD import calculate_etd_range\n", "from ecoscope.analysis.percentile import get_percentile_area\n", "from ecoscope.analysis.astronomy import is_night\n", + "from lonboard.colormap import apply_categorical_cmap, apply_continuous_cmap\n", "\n", - "ecoscope.init(selenium=True, silent=True)" + "ecoscope.init(silent=True)" ] }, { @@ -272,7 +274,7 @@ "m = EcoMap(width=800, height=600)\n", "\n", "# Add tiled base layer\n", - "m.add_basemap(\"OpenStreetMap\")\n", + "m.add_layer(EcoMap.get_named_tile_layer(\"OpenStreetMap\"))\n", "\n", "# Set DEM visualization parameters\n", "vis_params = {\"min\": 0, \"max\": 4000, \"opacity\": 0.5, \"palette\": [\"006633\", \"E5FFCC\", \"662A00\", \"D8D8D8\", \"F5F5F5\"]}\n", @@ -281,24 +283,21 @@ "dem = ee.Image(\"USGS/SRTMGL1_003\")\n", "m.add_ee_layer(dem.updateMask(dem.gt(0)), vis_params)\n", "\n", - "# Zoom in and add regions outlines\n", - "m.zoom_to_gdf(region_gdf)\n", - "m.add_gdf(\n", - " region_gdf,\n", - " style_kwds={\"fillOpacity\": 0.1, \"opacity\": 0.5, \"color\": \"black\"},\n", - " color=[\"#7fc97f\", \"#beaed4\", \"#fdc086\", \"#ffff99\"],\n", - ")\n", + "zone_colors = {\n", + " 1: EcoMap.hex_to_rgb(\"#7fc97f\"),\n", + " 2: EcoMap.hex_to_rgb(\"#beaed4\"),\n", + " 3: EcoMap.hex_to_rgb(\"#fdc086\"),\n", + " 4: EcoMap.hex_to_rgb(\"#ffff99\"),\n", + "}\n", + "cmap = apply_categorical_cmap(region_gdf.ZoneID, zone_colors)\n", + "m.add_polygon_layer(region_gdf, opacity=0.5, get_fill_color=cmap, zoom=True)\n", + "\n", "\n", "# Add trajectory\n", - "vehicle_gdf.geometry.explore(m=m, color=\"#468af7\")\n", + "m.add_path_layer(vehicle_gdf, get_width=200, get_color=EcoMap.hex_to_rgb(\"#468af7\"))\n", "\n", "# Add elephant sighting events\n", - "m.add_gdf(\n", - " events_gdf[[\"geometry\", \"serial_number\", \"location\", \"title\", \"event_type\"]],\n", - " color=\"#f746ad\",\n", - " marker_type=\"circle_marker\",\n", - " marker_kwds={\"radius\": 7, \"fill\": True, \"draggable\": False},\n", - ")\n", + "m.add_scatterplot_layer(events_gdf, get_radius=700, get_fill_color=EcoMap.hex_to_rgb(\"#f746ad\"))\n", "\n", "# Add title\n", "m.add_title(\"Elephant Sighting Map\")\n", @@ -332,6 +331,7 @@ ")\n", "\n", "m = EcoMap(width=800, height=600)\n", + "m.add_layer(EcoMap.get_named_tile_layer(\"OpenStreetMap\"))\n", "m.add_local_geotiff(path=os.path.join(output_dir, \"mara_dem.tif\"), zoom=True, cmap=\"jet\")\n", "m" ] @@ -360,14 +360,18 @@ "metadata": {}, "outputs": [], "source": [ - "colors = [\"#292965\" if is_night else \"#e7a553\" for is_night in movebank_relocations_gdf.is_night]\n", + "m = EcoMap(width=800, height=600)\n", + "\n", + "m.add_layer(EcoMap.get_named_tile_layer(\"OpenStreetMap\"))\n", "\n", - "m = movebank_relocations_gdf[[\"groupby_col\", \"fixtime\", \"geometry\", \"is_night\"]].explore()\n", - "m.zoom_to_bounds(movebank_relocations_gdf)\n", + "# Add day_night\n", + "colors = {True: EcoMap.hex_to_rgb(\"#292965\"), False: EcoMap.hex_to_rgb(\"#e7a553\")}\n", + "colors = apply_categorical_cmap(movebank_relocations_gdf.is_night, colors)\n", + "m.add_scatterplot_layer(movebank_relocations_gdf, get_radius=700, get_fill_color=colors, zoom=True)\n", "\n", "m.add_legend(title=\"Is Night\", labels=[\"True\", \"False\"], colors=[\"#292965\", \"#e7a553\"])\n", "m.add_north_arrow(placement=\"top-left\")\n", - "m.add_title(\"Day-Night Relocation Map\")\n", + "m.add_title(\"Day-Night Relocations\")\n", "\n", "m" ] @@ -403,14 +407,18 @@ "metadata": {}, "outputs": [], "source": [ - "colors = [\"#292965\" if is_night else \"#e7a553\" for is_night in movebank_trajectory_gdf.extra__is_night]\n", + "m = EcoMap(width=800, height=600)\n", "\n", - "m = movebank_trajectory_gdf[[\"groupby_col\", \"segment_start\", \"segment_end\", \"geometry\", \"extra__is_night\"]].explore()\n", - "m.zoom_to_gdf(movebank_trajectory_gdf)\n", + "m.add_layer(EcoMap.get_named_tile_layer(\"OpenStreetMap\"))\n", + "\n", + "# Add day_night\n", + "colors = {True: EcoMap.hex_to_rgb(\"#292965\"), False: EcoMap.hex_to_rgb(\"#e7a553\")}\n", + "colors = apply_categorical_cmap(movebank_trajectory_gdf.extra__is_night, colors)\n", + "m.add_path_layer(movebank_trajectory_gdf, get_width=200, get_color=colors, zoom=True)\n", "\n", "m.add_legend(title=\"Is Night\", labels=[\"True\", \"False\"], colors=[\"#292965\", \"#e7a553\"])\n", "m.add_north_arrow(placement=\"top-left\")\n", - "m.add_title(\"Day-Night Trajectory Map\")\n", + "m.add_title(\"Day-Night Relocations\")\n", "\n", "m" ] @@ -432,7 +440,7 @@ "m.add_speedmap(trajectory=movebank_trajectory_gdf, classification_method=\"equal_interval\", num_classes=6, bins=None)\n", "m.add_north_arrow(placement=\"top-left\")\n", "m.add_title(\"Elephant Speed Map\")\n", - "m.zoom_to_gdf(movebank_trajectory_gdf)\n", + "m.zoom_to_bounds(movebank_trajectory_gdf)\n", "\n", "m" ] @@ -491,11 +499,17 @@ ").to_crs(4326)\n", "\n", "m = EcoMap(width=800, height=600, static=True)\n", - "m.add_gdf(percentile_areas, column=\"percentile\", cmap=\"RdYlGn\")\n", - "m.zoom_to_gdf(percentile_areas)\n", + "m.add_layer(EcoMap.get_named_tile_layer(\"OpenStreetMap\"))\n", + "\n", + "\n", + "col = percentile_areas[\"percentile\"]\n", + "normalized = (col - col.min()) / (col.max() - col.min())\n", + "colormap = apply_continuous_cmap(normalized, mpl.colormaps[\"RdYlGn\"])\n", + "\n", + "m.add_polygon_layer(percentile_areas, get_fill_color=colormap, zoom=True)\n", "\n", - "m.add_title(\"Salif ETD Range\")\n", "m.add_north_arrow(placement=\"top-left\")\n", + "m.add_title(\"Salif ETD Range\")\n", "\n", "m" ] From 65507c7b30f3a85a6fbf71f2cbf06e021a9b7b11 Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Fri, 28 Jun 2024 21:52:50 +1000 Subject: [PATCH 26/28] remove speedmap and png export (this is replaced by an widget on the map) --- notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb | 44 ++------------------- 1 file changed, 3 insertions(+), 41 deletions(-) diff --git a/notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb b/notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb index c8b4287b..c9eb646f 100644 --- a/notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb +++ b/notebooks/04. EcoMap & EcoPlot/EcoMap.ipynb @@ -238,7 +238,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Basic Two-Layer EcoMap" + "### Basic EcoMap" ] }, { @@ -332,7 +332,7 @@ "\n", "m = EcoMap(width=800, height=600)\n", "m.add_layer(EcoMap.get_named_tile_layer(\"OpenStreetMap\"))\n", - "m.add_local_geotiff(path=os.path.join(output_dir, \"mara_dem.tif\"), zoom=True, cmap=\"jet\")\n", + "m.add_geotiff(path=os.path.join(output_dir, \"mara_dem.tif\"), zoom=True, cmap=\"jet\")\n", "m" ] }, @@ -423,28 +423,6 @@ "m" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Speed Map" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "m = EcoMap()\n", - "m.add_speedmap(trajectory=movebank_trajectory_gdf, classification_method=\"equal_interval\", num_classes=6, bins=None)\n", - "m.add_north_arrow(placement=\"top-left\")\n", - "m.add_title(\"Elephant Speed Map\")\n", - "m.zoom_to_bounds(movebank_trajectory_gdf)\n", - "\n", - "m" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -536,22 +514,6 @@ "source": [ "m.to_html(os.path.join(output_dir, \"ecomap.html\"))" ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### As PNG" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "m.to_png(os.path.join(output_dir, \"ecomap.png\"))" - ] } ], "metadata": { @@ -570,7 +532,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.12" + "version": "3.12.3" } }, "nbformat": 4, From 5ec29c18f9b17ca50c23a012928c3ce39b36131d Mon Sep 17 00:00:00 2001 From: Alex Morling Date: Fri, 28 Jun 2024 22:08:59 +1000 Subject: [PATCH 27/28] update etd notebook --- .../Elliptical Time Density (ETD).ipynb | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/notebooks/03. Home Range & Movescape/Elliptical Time Density (ETD).ipynb b/notebooks/03. Home Range & Movescape/Elliptical Time Density (ETD).ipynb index 7a088bb0..93be1b48 100644 --- a/notebooks/03. Home Range & Movescape/Elliptical Time Density (ETD).ipynb +++ b/notebooks/03. Home Range & Movescape/Elliptical Time Density (ETD).ipynb @@ -48,7 +48,6 @@ "import ecoscope\n", "from ecoscope.analysis.UD import calculate_etd_range\n", "from ecoscope.analysis.percentile import get_percentile_area\n", - "from ecoscope.mapping import EcoMap\n", "\n", "ecoscope.init()" ] @@ -290,7 +289,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Visualize Percentiles" + "## Percentiles" ] }, { @@ -303,18 +302,6 @@ " percentile_levels=[50, 60, 70, 80, 90, 99.9], raster_path=etd.at[\"Salif Keita\"], subject_id=\"Salif Keita\"\n", ")" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "m = EcoMap(width=800, height=600)\n", - "\n", - "m.add_gdf(salif, column=\"percentile\", cmap=\"RdYlGn\", zoom=True)\n", - "m" - ] } ], "metadata": { @@ -333,7 +320,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.12" + "version": "3.12.3" } }, "nbformat": 4, From 1473053690e0383deb324a3ef2e178841e74df85 Mon Sep 17 00:00:00 2001 From: atmorling Date: Fri, 28 Jun 2024 23:04:26 +1000 Subject: [PATCH 28/28] Update tests/test_ecomap.py Co-authored-by: Charles Stern <62192187+cisaacstern@users.noreply.github.com> --- tests/test_ecomap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_ecomap.py b/tests/test_ecomap.py index 731d7e8a..cb942c88 100644 --- a/tests/test_ecomap.py +++ b/tests/test_ecomap.py @@ -88,7 +88,7 @@ def test_add_ee_layer_image_collection(): assert isinstance(m.layers[1], BitmapTileLayer) -@pytest.mark.skipif(not pytest.earthengine, reason="No onnection to EarthEngine.") +@pytest.mark.skipif(not pytest.earthengine, reason="No connection to EarthEngine.") def test_add_ee_layer_feature_collection(): m = EcoMap() vis_params = {"min": 0, "max": 4000, "opacity": 0.5, "palette": ["006633", "E5FFCC", "662A00", "D8D8D8", "F5F5F5"]}