Skip to content

Instantly share code, notes, and snippets.

@mdsumner
Created March 4, 2026 10:59
Show Gist options
  • Select an option

  • Save mdsumner/bd96819596598f7a4ce2580dcd94b283 to your computer and use it in GitHub Desktop.

Select an option

Save mdsumner/bd96819596598f7a4ce2580dcd94b283 to your computer and use it in GitHub Desktop.

GDAL-ready basemap tiles

Access common web basemap tile services via GDAL. Returns either WMTS connection strings (for ESRI services with WMTS support) or TMS minidriver XML for XYZ tile services.

Function

basemap <- function(name = NULL, url = NULL, api_key = NULL, tile_level = 19L, bands = 3L) {
  providers <- list(
    # OpenStreetMap (no key)
    OpenStreetMap = "https://tile.openstreetmap.org/${z}/${x}/${y}.png",
    `OpenStreetMap.DE` = "https://tile.openstreetmap.de/${z}/${x}/${y}.png",
    `OpenStreetMap.France` = "https://a.tile.openstreetmap.fr/osmfr/${z}/${x}/${y}.png",
    `OpenStreetMap.HOT` = "https://a.tile.openstreetmap.fr/hot/${z}/${x}/${y}.png",
    OpenTopoMap = "https://a.tile.opentopomap.org/${z}/${x}/${y}.png",
    # CartoDB (no key)
    `CartoDB.Positron` = "https://a.basemaps.cartocdn.com/light_all/${z}/${x}/${y}.png",
    `CartoDB.PositronNoLabels` = "https://a.basemaps.cartocdn.com/light_nolabels/${z}/${x}/${y}.png",
    `CartoDB.PositronOnlyLabels` = "https://a.basemaps.cartocdn.com/light_only_labels/${z}/${x}/${y}.png",
    `CartoDB.DarkMatter` = "https://a.basemaps.cartocdn.com/dark_all/${z}/${x}/${y}.png",
    `CartoDB.DarkMatterNoLabels` = "https://a.basemaps.cartocdn.com/dark_nolabels/${z}/${x}/${y}.png",
    `CartoDB.DarkMatterOnlyLabels` = "https://a.basemaps.cartocdn.com/dark_only_labels/${z}/${x}/${y}.png",
    `CartoDB.Voyager` = "https://a.basemaps.cartocdn.com/rastertiles/voyager/${z}/${x}/${y}.png",
    `CartoDB.VoyagerNoLabels` = "https://a.basemaps.cartocdn.com/rastertiles/voyager_nolabels/${z}/${x}/${y}.png",
    `CartoDB.VoyagerOnlyLabels` = "https://a.basemaps.cartocdn.com/rastertiles/voyager_only_labels/${z}/${x}/${y}.png",
    # ESRI WMTS (no key)
    `Esri.WorldImagery` = "WMTS:https://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/WMTS/1.0.0/WMTSCapabilities.xml,layer=World_Imagery",
    `Esri.WorldStreetMap` = "WMTS:https://services.arcgisonline.com/arcgis/rest/services/World_Street_Map/MapServer/WMTS/1.0.0/WMTSCapabilities.xml,layer=World_Street_Map",
    `Esri.WorldTopoMap` = "WMTS:https://services.arcgisonline.com/arcgis/rest/services/World_Topo_Map/MapServer/WMTS/1.0.0/WMTSCapabilities.xml,layer=World_Topo_Map",
    `Esri.WorldTerrain` = "WMTS:https://services.arcgisonline.com/arcgis/rest/services/World_Terrain_Base/MapServer/WMTS/1.0.0/WMTSCapabilities.xml,layer=World_Terrain_Base",
    `Esri.WorldShadedRelief` = "WMTS:https://services.arcgisonline.com/arcgis/rest/services/World_Shaded_Relief/MapServer/WMTS/1.0.0/WMTSCapabilities.xml,layer=World_Shaded_Relief",
    `Esri.NatGeoWorldMap` = "WMTS:https://services.arcgisonline.com/arcgis/rest/services/NatGeo_World_Map/MapServer/WMTS/1.0.0/WMTSCapabilities.xml,layer=NatGeo_World_Map",
    # ESRI TMS (no key)
    `Esri.OceanBasemap` = "https://services.arcgisonline.com/arcgis/rest/services/Ocean/World_Ocean_Base/MapServer/tile/${z}/${y}/${x}",
    `Esri.WorldGrayCanvas` = "https://services.arcgisonline.com/arcgis/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/${z}/${y}/${x}",
    # Stadia (API key required)
    `Stadia.AlidadeSmooth` = "https://tiles.stadiamaps.com/tiles/alidade_smooth/${z}/${x}/${y}.png",
    `Stadia.AlidadeSmoothDark` = "https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/${z}/${x}/${y}.png",
    `Stadia.OSMBright` = "https://tiles.stadiamaps.com/tiles/osm_bright/${z}/${x}/${y}.png",
    `Stadia.Outdoors` = "https://tiles.stadiamaps.com/tiles/outdoors/${z}/${x}/${y}.png",
    `Stadia.StamenToner` = "https://tiles.stadiamaps.com/tiles/stamen_toner/${z}/${x}/${y}.png",
    `Stadia.StamenTonerBackground` = "https://tiles.stadiamaps.com/tiles/stamen_toner_background/${z}/${x}/${y}.png",
    `Stadia.StamenTonerLines` = "https://tiles.stadiamaps.com/tiles/stamen_toner_lines/${z}/${x}/${y}.png",
    `Stadia.StamenTonerLabels` = "https://tiles.stadiamaps.com/tiles/stamen_toner_labels/${z}/${x}/${y}.png",
    `Stadia.StamenTonerLite` = "https://tiles.stadiamaps.com/tiles/stamen_toner_lite/${z}/${x}/${y}.png",
    `Stadia.StamenWatercolor` = "https://tiles.stadiamaps.com/tiles/stamen_watercolor/${z}/${x}/${y}.png",
    `Stadia.StamenTerrain` = "https://tiles.stadiamaps.com/tiles/stamen_terrain/${z}/${x}/${y}.png",
    `Stadia.StamenTerrainBackground` = "https://tiles.stadiamaps.com/tiles/stamen_terrain_background/${z}/${x}/${y}.png",
    `Stadia.StamenTerrainLabels` = "https://tiles.stadiamaps.com/tiles/stamen_terrain_labels/${z}/${x}/${y}.png",
    # Thunderforest (API key required)
    `Thunderforest.OpenCycleMap` = "https://tile.thunderforest.com/cycle/${z}/${x}/${y}.png",
    `Thunderforest.Transport` = "https://tile.thunderforest.com/transport/${z}/${x}/${y}.png",
    `Thunderforest.TransportDark` = "https://tile.thunderforest.com/transport-dark/${z}/${x}/${y}.png",
    `Thunderforest.SpinalMap` = "https://tile.thunderforest.com/spinal-map/${z}/${x}/${y}.png",
    `Thunderforest.Landscape` = "https://tile.thunderforest.com/landscape/${z}/${x}/${y}.png",
    `Thunderforest.Outdoors` = "https://tile.thunderforest.com/outdoors/${z}/${x}/${y}.png",
    `Thunderforest.Pioneer` = "https://tile.thunderforest.com/pioneer/${z}/${x}/${y}.png",
    `Thunderforest.MobileAtlas` = "https://tile.thunderforest.com/mobile-atlas/${z}/${x}/${y}.png",
    `Thunderforest.Neighbourhood` = "https://tile.thunderforest.com/neighbourhood/${z}/${x}/${y}.png"
  )
  
  needs_key <- c("Stadia", "Thunderforest")
  
  if (!is.null(name)) {
    name <- match.arg(name, names(providers))
    tile_url <- providers[[name]]
    if (startsWith(tile_url, "WMTS:")) return(tile_url)
    provider <- strsplit(name, "\\.")[[1]][1]
    if (provider %in% needs_key) {
      if (is.null(api_key)) stop(sprintf("%s requires api_key", provider))
      key_param <- if (provider == "Stadia") "api_key" else "apikey"
      tile_url <- paste0(tile_url, "?", key_param, "=", api_key)
    }
  } else if (!is.null(url)) {
    tile_url <- sub("/$", "", url)
    if (!grepl("\\$\\{", tile_url)) tile_url <- paste0(tile_url, "/${z}/${x}/${y}.png")
  } else stop("Provide 'name' or 'url'")
  
  sprintf('<GDAL_WMS><Service name="TMS"><ServerUrl>%s</ServerUrl></Service><DataWindow><UpperLeftX>-20037508.34</UpperLeftX><UpperLeftY>20037508.34</UpperLeftY><LowerRightX>20037508.34</LowerRightX><LowerRightY>-20037508.34</LowerRightY><TileLevel>%d</TileLevel><TileCountX>1</TileCountX><TileCountY>1</TileCountY><YOrigin>top</YOrigin></DataWindow><Projection>EPSG:3857</Projection><BlockSizeX>256</BlockSizeX><BlockSizeY>256</BlockSizeY><BandsCount>%d</BandsCount><Cache/></GDAL_WMS>', tile_url, tile_level, bands)
}

Usage

# No authentication required
basemap("OpenStreetMap")
basemap("CartoDB.DarkMatter")
basemap("OpenTopoMap")

# ESRI WMTS (composable with vrt://)
basemap("Esri.WorldImagery")
#> "WMTS:https://services.arcgisonline.com/..."

# WMTS + vrt:// for zoom control
dsn <- paste0("vrt://", basemap("Esri.WorldImagery"), "?ovr=12")

# API key required
basemap("Stadia.StamenWatercolor", api_key = "your-stadia-key")
basemap("Thunderforest.OpenCycleMap", api_key = "your-thunderforest-key")

# Custom URL
basemap(url = "https://example.com/tiles")

# With terra
library(terra)
r <- rast(basemap("Esri.WorldImagery"))

Coverage

No authentication required (22 providers)

Provider Type Notes
OpenStreetMap TMS Standard OSM tiles
OpenStreetMap.DE TMS German server
OpenStreetMap.France TMS French server
OpenStreetMap.HOT TMS Humanitarian style
OpenTopoMap TMS Topographic
CartoDB.Positron TMS Light gray
CartoDB.PositronNoLabels TMS Light gray, no labels
CartoDB.PositronOnlyLabels TMS Labels only
CartoDB.DarkMatter TMS Dark gray
CartoDB.DarkMatterNoLabels TMS Dark gray, no labels
CartoDB.DarkMatterOnlyLabels TMS Labels only
CartoDB.Voyager TMS Colored
CartoDB.VoyagerNoLabels TMS Colored, no labels
CartoDB.VoyagerOnlyLabels TMS Labels only
Esri.WorldImagery WMTS Satellite imagery
Esri.WorldStreetMap WMTS Street map
Esri.WorldTopoMap WMTS Topographic
Esri.WorldTerrain WMTS Terrain base
Esri.WorldShadedRelief WMTS Shaded relief
Esri.NatGeoWorldMap WMTS National Geographic style
Esri.OceanBasemap TMS Ocean/bathymetry
Esri.WorldGrayCanvas TMS Light gray canvas

API key required (22 providers)

Provider Style
Stadia.AlidadeSmooth Light, clean
Stadia.AlidadeSmoothDark Dark, clean
Stadia.OSMBright Bright OSM
Stadia.Outdoors Outdoor/hiking
Stadia.StamenToner High contrast B&W
Stadia.StamenTonerBackground Toner without labels
Stadia.StamenTonerLines Toner lines only
Stadia.StamenTonerLabels Toner labels only
Stadia.StamenTonerLite Lighter toner
Stadia.StamenWatercolor Artistic watercolor
Stadia.StamenTerrain Terrain with relief
Stadia.StamenTerrainBackground Terrain without labels
Stadia.StamenTerrainLabels Terrain labels only
Provider Style
Thunderforest.OpenCycleMap Cycling routes
Thunderforest.Transport Public transport
Thunderforest.TransportDark Transport, dark
Thunderforest.SpinalMap Artistic/unique
Thunderforest.Landscape General landscape
Thunderforest.Outdoors Hiking/outdoor
Thunderforest.Pioneer Vintage style
Thunderforest.MobileAtlas Mobile optimized
Thunderforest.Neighbourhood Local detail

Notes

  • WMTS strings work directly with vrt:// for zoom control: vrt://WMTS:...?ovr=N
  • TMS XML requires file indirection for vrt:// chaining
  • All providers are Web Mercator (EPSG:3857)
  • Tile size is 256x256
  • Max zoom varies by provider (default 19)
  • ESRI tile URLs use ${z}/${y}/${x} (y before x, no extension)
  • Standard XYZ tiles use ${z}/${x}/${y}.png

API Key Registration

Provider Free tier Registration
Stadia Yes (with limits) https://stadiamaps.com
Thunderforest Yes (with limits) https://thunderforest.com

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment