From 500c7b757e7f99e64a811dd13581ab0ce7484bf0 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Fri, 24 May 2024 22:19:07 -0400 Subject: [PATCH] Add Google map tiles (#1963) * Add Google map tiles * Remove googlemaps dependency * Fix name and attribution * Add name variations * Add Google map tiles to basemap list * Add support for returning all map types as a dict * Use Google Roadmap by default if API Key is available * Make gmap tiles available in core module * Fix docs build error * Remove Google Traffic and Streetview * Set line wrap for docstrings * Improve google_maps_api_key function * Add GoogleMapsTileProvider class * Add backward compatibility for map alias * Fix add_basemap Google tiles issue * Reduce dependencies on hardcoded map name prefixes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Allow basemap as a string --------- Co-authored-by: Nathaniel Schmitz Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .gitignore | 1 + geemap/basemaps.py | 150 ++++++++++++++++++++++++++++++++++++++++++++- geemap/common.py | 23 ++++++- geemap/core.py | 51 ++++++++++----- geemap/geemap.py | 42 +++---------- 5 files changed, 217 insertions(+), 50 deletions(-) diff --git a/.gitignore b/.gitignore index fb6887e4c6..ef3ab1d719 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,4 @@ ENV/ # IDE settings .vscode/ docs/changelog_update.py +oryx-build-commands.txt diff --git a/geemap/basemaps.py b/geemap/basemaps.py index b648e77ae9..315a62e7e5 100644 --- a/geemap/basemaps.py +++ b/geemap/basemaps.py @@ -19,11 +19,14 @@ import collections import os -import requests +from typing import Any, Optional + import folium import ipyleaflet +import requests import xyzservices -from .common import check_package, planet_tiles + +from .common import check_package, get_google_maps_api_key, planet_tiles XYZ_TILES = { "OpenStreetMap": { @@ -237,6 +240,149 @@ custom_tiles = {"xyz": XYZ_TILES, "wms": WMS_TILES} +class GoogleMapsTileProvider(xyzservices.TileProvider): + """Google Maps TileProvider.""" + + MAP_TYPE_CONFIG = { + "roadmap": {"mapType": "roadmap"}, + "satellite": {"mapType": "satellite"}, + "terrain": { + "mapType": "terrain", + "layerTypes": ["layerRoadmap"], + }, + "hybrid": { + "mapType": "satellite", + "layerTypes": ["layerRoadmap"], + }, + } + + def __init__( + self, + map_type: str = "roadmap", + language: str = "en-Us", + region: str = "US", + api_key: Optional[str] = None, + **kwargs: Any, + ): + """ + Generates Google Map tiles using the provided parameters. To get an API key + and enable Map Tiles API, visit + https://developers.google.com/maps/get-started#create-project. + You can set the API key using the environment variable + `GOOGLE_MAPS_API_KEY` or by passing it as an argument. + + Args: + map_type (str, optional): The type of map to generate. Options are + 'roadmap', 'satellite', 'terrain', 'hybrid', 'traffic', 'streetview'. + Defaults to 'roadmap'. + language (str, optional): An IETF language tag that specifies the + language used to display information on the tiles, such as 'zh-Cn'. + Defaults to 'en-Us'. + region (str, optional): A Common Locale Data Repository region + identifier (two uppercase letters) that represents the physical + location of the user. Defaults to 'US'. + api_key (str, optional): The API key to use for the Google Maps API. + If not provided, it will try to get it from the environment or + Colab user data with the key 'MAPS_API_KEY'. Defaults to None. + **kwargs: Additional parameters to pass to the map generation. For more + info, visit https://bit.ly/3UhbZKU + + Raises: + ValueError: If the API key is not provided and cannot be found in the + environment or Colab user data. + ValueError: If the map_type is not one of the allowed types. + + Example: + >>> from geemap.basemaps import GoogleMapsTileProvider + >>> m = geemap.Map() + >>> basemap = GoogleMapsTileProvider(map_type='roadmap', + language="en-Us", region="US", scale="scaleFactor2x", highDpi=True) + >>> m.add_basemap(basemap) + + Returns: + TileProvider object: A TileProvider object with the Google Maps tile. + """ + + key = api_key or get_google_maps_api_key() + if key is None: + raise ValueError( + "API key is required to access Google Maps API. To get an API " + "key and enable Map Tiles API, visit " + "https://developers.google.com/maps/get-started#create-project" + ) + + if map_type not in self.MAP_TYPE_CONFIG: + raise ValueError(f"map_type must be one of: {self.MAP_TYPE_CONFIG.keys()}") + + request_url = f"https://tile.googleapis.com/v1/createSession?key={key}" + response = requests.post( + url=request_url, + headers={"Content-Type": "application/json"}, + json={ + **self.MAP_TYPE_CONFIG[map_type], + "language": language, + "region": region, + **kwargs, + }, + timeout=3, + ) + + if response.status_code == requests.codes.ok: + json = response.json() + map_name = map_type.capitalize() + super().__init__( + { + "url": f"https://tile.googleapis.com/v1/2dtiles/{{z}}/{{x}}/{{y}}?session={json['session']}&key={{accessToken}}", + "attribution": f"© Google {map_name}", + "accessToken": key, + "name": f"Google.{map_name}", + "ext": json["imageFormat"], + "tileSize": json["tileWidth"], + } + ) + else: + raise RuntimeError( + f"Error creating a Maps API session:\n{response.json()}." + ) + + +def get_google_map_tile_providers( + language: str = "en-Us", + region: str = "US", + api_key: Optional[str] = None, + **kwargs: Any, +): + """ + Generates a dictionary of Google Map tile providers for different map types. + + Args: + language (str, optional): An IETF language tag that specifies the + language used to display information on the tiles, such as 'zh-Cn'. + Defaults to 'en-Us'. + region (str, optional): A Common Locale Data Repository region + identifier (two uppercase letters) that represents the physical + location of the user. Defaults to 'US'. + api_key (str, optional): The API key to use for the Google Maps API. + If not provided, it will try to get it from the environment or + Colab user data with the key 'MAPS_API_KEY'. Defaults to None. + **kwargs: Additional parameters to pass to the map generation. For more + info, visit https://bit.ly/3UhbZKU + + Returns: + dict: A dictionary where the keys are the map types + ('roadmap', 'satellite', 'terrain', 'hybrid') + and the values are the corresponding GoogleMapsTileProvider objects. + """ + gmap_providers = {} + + for m_type in GoogleMapsTileProvider.MAP_TYPE_CONFIG: + gmap_providers[m_type] = GoogleMapsTileProvider( + map_type=m_type, language=language, region=region, api_key=api_key, **kwargs + ) + + return gmap_providers + + def get_xyz_dict(free_only=True, france=False): """Returns a dictionary of xyz services. diff --git a/geemap/common.py b/geemap/common.py index 0440f97d12..545b4db44c 100644 --- a/geemap/common.py +++ b/geemap/common.py @@ -24,7 +24,7 @@ import ee import ipywidgets as widgets from ipytree import Node, Tree -from typing import Union, List, Dict, Optional, Tuple +from typing import Union, List, Dict, Optional, Tuple, Any try: from IPython.display import display, IFrame, Javascript @@ -16167,3 +16167,24 @@ def is_on_aws(): if item.endswith(".aws") or "ec2-user" in item: on_aws = True return on_aws + + +def get_google_maps_api_key(key: str = "GOOGLE_MAPS_API_KEY") -> Optional[str]: + """ + Retrieves the Google Maps API key from the environment or Colab user data. + + Args: + key (str, optional): The name of the environment variable or Colab user + data key where the API key is stored. Defaults to + 'GOOGLE_MAPS_API_KEY'. + + Returns: + str: The API key, or None if it could not be found. + """ + if in_colab_shell(): + from google.colab import userdata + + if api_key := userdata.get(key): + return api_key + + return os.environ.get(key, None) diff --git a/geemap/core.py b/geemap/core.py index 2ef6ac1b8b..2cf8896e02 100644 --- a/geemap/core.py +++ b/geemap/core.py @@ -402,12 +402,12 @@ class Map(ipyleaflet.Map, MapInterface): "scroll_wheel_zoom": True, } - _BASEMAP_ALIASES: Dict[str, str] = { - "DEFAULT": "OpenStreetMap.Mapnik", - "ROADMAP": "Esri.WorldStreetMap", - "SATELLITE": "Esri.WorldImagery", - "TERRAIN": "Esri.WorldTopoMap", - "HYBRID": "Esri.WorldImagery", + _BASEMAP_ALIASES: Dict[str, List[str]] = { + "DEFAULT": ["Google.Roadmap", "OpenStreetMap.Mapnik"], + "ROADMAP": ["Google.Roadmap", "Esri.WorldStreetMap"], + "SATELLITE": ["Google.Satellite", "Esri.WorldImagery"], + "TERRAIN": ["Google.Terrain", "Esri.WorldTopoMap"], + "HYBRID": ["Google.Hybrid", "Esri.WorldImagery"], } _USER_AGENT_PREFIX = "geemap-core" @@ -458,6 +458,13 @@ def _basemap_selector(self) -> Optional[map_widgets.Basemap]: def __init__(self, **kwargs): self._available_basemaps = self._get_available_basemaps() + # Use the first basemap in the list of available basemaps. + if "basemap" not in kwargs: + kwargs["basemap"] = next(iter(self._available_basemaps.values())) + elif "basemap" in kwargs and isinstance(kwargs["basemap"], str): + if kwargs["basemap"] in self._available_basemaps: + kwargs["basemap"] = self._available_basemaps.get(kwargs["basemap"]) + if "width" in kwargs: self.width: str = kwargs.pop("width", "100%") self.height: str = kwargs.pop("height", "600px") @@ -846,21 +853,35 @@ def _replace_basemap(self, basemap_name: str) -> None: def _get_available_basemaps(self) -> Dict[str, Any]: """Convert xyz tile services to a dictionary of basemaps.""" + tile_providers = list(basemaps.get_xyz_dict().values()) + if common.get_google_maps_api_key(): + tile_providers = tile_providers + list( + basemaps.get_google_map_tile_providers().values() + ) + ret_dict = {} - for tile_info in basemaps.get_xyz_dict().values(): + for tile_info in tile_providers: tile_info["url"] = tile_info.build_url() ret_dict[tile_info["name"]] = tile_info - extra_dict = {k: ret_dict[v] for k, v in self._BASEMAP_ALIASES.items()} - return {**extra_dict, **ret_dict} + + # Each alias needs to point to a single map. For each alias, pick the + # first aliased map in `self._BASEMAP_ALIASES`. + aliased_maps = {} + for alias, maps in self._BASEMAP_ALIASES.items(): + for map_name in maps: + if provider := ret_dict.get(map_name): + aliased_maps[alias] = provider + break + return {**aliased_maps, **ret_dict} def _get_preferred_basemap_name(self, basemap_name: str) -> str: """Returns the aliased basemap name.""" - try: - return list(self._BASEMAP_ALIASES.keys())[ - list(self._BASEMAP_ALIASES.values()).index(basemap_name) - ] - except ValueError: - return basemap_name + reverse_aliases = {} + for alias, maps in self._BASEMAP_ALIASES.items(): + for map_name in maps: + if map_name not in reverse_aliases: + reverse_aliases[map_name] = alias + return reverse_aliases.get(basemap_name, basemap_name) def _on_layers_change(self, change) -> None: del change # Unused. diff --git a/geemap/geemap.py b/geemap/geemap.py index 0cd9f057c6..3e06493219 100644 --- a/geemap/geemap.py +++ b/geemap/geemap.py @@ -113,15 +113,6 @@ def __init__(self, **kwargs): if "max_zoom" not in kwargs: kwargs["max_zoom"] = 24 - # Use any basemap available through the basemap module, such as 'ROADMAP', 'OpenTopoMap' - if "basemap" in kwargs: - kwargs["basemap"] = check_basemap(kwargs["basemap"]) - if kwargs["basemap"] in basemaps.keys(): - kwargs["basemap"] = get_basemap(kwargs["basemap"]) - kwargs["add_google_map"] = False - else: - kwargs.pop("basemap") - self._xyz_dict = get_xyz_dict() self.baseclass = "ipyleaflet" @@ -411,12 +402,14 @@ def get_scale(self): getScale = get_scale - def add_basemap(self, basemap="ROADMAP", show=True, **kwargs): + def add_basemap( + self, basemap: Optional[str] = "ROADMAP", show: Optional[bool] = True, **kwargs + ) -> None: """Adds a basemap to the map. Args: basemap (str, optional): Can be one of string from basemaps. Defaults to 'ROADMAP'. - visible (bool, optional): Whether the basemap is visible or not. Defaults to True. + show (bool, optional): Whether the basemap is visible or not. Defaults to True. **kwargs: Keyword arguments for the TileLayer. """ import xyzservices @@ -424,21 +417,11 @@ def add_basemap(self, basemap="ROADMAP", show=True, **kwargs): try: layer_names = self.get_layer_names() - map_dict = { - "ROADMAP": "Esri.WorldStreetMap", - "SATELLITE": "Esri.WorldImagery", - "TERRAIN": "Esri.WorldTopoMap", - "HYBRID": "Esri.WorldImagery", - } - if isinstance(basemap, str): - if basemap.upper() in map_dict: - if basemap in os.environ: - if "name" in kwargs: - kwargs["name"] = basemap - basemap = os.environ[basemap] - else: - basemap = map_dict[basemap.upper()] + for map_name, tile_provider in self._available_basemaps.items(): + if basemap.upper() == map_name.upper(): + basemap = tile_provider + break if isinstance(basemap, xyzservices.TileProvider): name = basemap.name @@ -946,18 +929,13 @@ def _on_basemap_changed(self, basemap_name): bounds = [bounds[0][1], bounds[0][0], bounds[1][1], bounds[1][0]] self.zoom_to_bounds(bounds) - def add_basemap_widget(self, value="OpenStreetMap", position="topright"): + def add_basemap_widget(self, position="topright"): """Add the Basemap GUI to the map. Args: - value (str): The default value from basemaps to select. Defaults to "OpenStreetMap". position (str, optional): The position of the Inspector GUI. Defaults to "topright". """ - super()._add_basemap_selector( - position, basemaps=list(basemaps.keys()), value=value - ) - if basemap_selector := self._basemap_selector: - basemap_selector.on_basemap_changed = self._on_basemap_changed + super()._add_basemap_selector(position=position) def add_draw_control(self, position="topleft"): """Add a draw control to the map