Skip to content

Commit

Permalink
Add Google map tiles (#1963)
Browse files Browse the repository at this point in the history
* 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 <schmitznathaniel@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored May 25, 2024
1 parent b115dfb commit 500c7b7
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 50 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,4 @@ ENV/
# IDE settings
.vscode/
docs/changelog_update.py
oryx-build-commands.txt
150 changes: 148 additions & 2 deletions geemap/basemaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 22 additions & 1 deletion geemap/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
51 changes: 36 additions & 15 deletions geemap/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
42 changes: 10 additions & 32 deletions geemap/geemap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -411,34 +402,26 @@ 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

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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 500c7b7

Please sign in to comment.