预计阅读

空间数据可视化与 R 语言(中篇)





本文引用的所有信息均为公开信息,仅代表作者本人观点,与就职单位无关。

1 本文概览

数据可视化在数据探索分析中扮演了极其重要的角色,帮助了解数据的质量、分布和潜在规律,为建模和分析提供思路和假设。同时,有助于阐述分析结果,交流分享。但要注意使用场景,切记过于花哨,引入一些不必要的炫酷手段,比如各种 3D 图,蜂窝图等。

本文告诉读者如何在 R 语言中调用生活中常见的谷歌、必应、百度和高德等地图服务。介绍一些网址地位、地理编码和坐标转化的 R 包,为空间数据操作、可视化奠定基础。然后,介绍展示空间数据分布的常用图形及其R包实现,再重点介绍专题地图,并以不同 R 包的不同风格实现。

2 地图服务

比较常见的地图服务,国外有谷歌、微软,国内有百度、高德,凡是提供本地生活服务的公司都或多或少地依赖高精地图,如美团外卖,阿里飞猪等。本节主要带领读者了解地图服务,以及使用 R 语言来调地图服务,为增加代入感,笔者就从「中国矿业大学(北京)学院路校区」这个地标的 IP 定位开始。首先用 curl 工具请求网站 https://ipinfo.io/1,它会自动获取用户上网使用的 IP 以及 IP 所处的地理位置,如下:

curl ipinfo.io
{
  "ip": "223.71.83.98",
  "city": "Beijing",
  "region": "Beijing",
  "country": "CN",
  "loc": "39.9288,116.3890",
  "org": "AS56048 China Mobile Communicaitons Corporation"
}

可知学校所处位置:纬度 39.9288 经度 116.3890,当然这离高精地图还有十万八千里。

2.1 百度地图

杜亚磊开发的baidumap包可以调用百度地图服务获取地理可视化的背景底图。百度是国内比较早的互联网公司,之前介绍的 Apache Echarts 也是百度可视化团队领头开发的,百度地理信息可视化平台使用的地理可视化组件来自开源的mapv

在百度地图上,根据地理名称获得经纬度信息,获得经纬度后,可以将位置标记在地图上,下面以「中国矿业大学(北京)」为例。

remotes::install_github('badbye/baidumap')

BaiduMap_API_Key 是从百度 LBS 云服务申请到的地图 API 访问令牌,保存到本地 .Renviron 文件里 BaiduMap_API_Key=[key],这样启动 R 软件时就会自动载入。

library(baidumap)
options(baidumap.key = Sys.getenv("BaiduMap_API_Key"))
getCoordinate('中国矿业大学(北京)学院路校区', formatted = T)
#   longtitude     latitude 
# 116.35376693  40.00382807

如图2.1,在地图上显示解析出来的位置,可见定位在矿大1号楼

cumtb_map <- getBaiduMap("中国矿业大学(北京)学院路校区", zoom = 17)
cumtb_coordinate <- getCoordinate("中国矿业大学(北京)学院路校区", formatted = T)
cumtb_coordinate <- data.frame(t(cumtb_coordinate))
# 调 ggmap 绘制背景地图
ggmap::ggmap(cumtb_map) +
  ggplot2::geom_point(aes(x = longtitude, y = latitude),
    data = cumtb_coordinate, col = "red", size = 5
  )

图 2.1: 百度地图-中国矿业大学(北京)学院路校区

2.2 高德地图

高德和百度都是有甲级测绘资质的单位,意味着地图数据符合国家要求,来源权威可用。大家熟知的全球定位系统(GPS)采用 WGS 84,而 GCJ 02 由 WGS 84 加密后的坐标系,是由中国国家测绘局制定的地理坐标系统,又称火星坐标系。高德地图采用火星坐标系 GCJ 02(国测局 Guo Ce Ju,即国家测绘局),百度地图的坐标系 BD 09 在 GCJ 02 坐标系基础上再次加密,加密过程不可逆,是一种非线性加密方式,反向解密后的坐标在部分区域的定位差别很大。另外,值得注意的是对同一目标不同公司提供的定位可能是不同的,因为所选的地理参考系不同,常用的三种坐标系见文档坐标系

高德提供很多调用地图服务的 API 接口,如将其它坐标转化为高德坐标的坐标转化服务,前提是先在高德开放平台上申请Web服务API类型KEY。继续以地标「中国矿业大学(北京)学院路校区」为例,下面在 R 语言环境中,使用 httr[1]调用高德地图服务将百度坐标转化为高德坐标。

library(httr)
# 向高德地图 API 发 GET 请求
tmp <- GET(
  url = "https://restapi.amap.com/",
  path = "v3/assistant/coordinate/convert",
  query = list(
    # 原坐标,经纬度小数点后不得超过6位
    locations = "40.003828,116.353766", 
    # 原坐标参考系,还支持 GPS 坐标,取值 'gps'
    coordsys = "baidu", 
    # 返回数据格式,还支持 xml
    output = "json", 
    # 高德地图 WEB 服务 API 访问令牌
    key = Sys.getenv("AMAP_KEY") 
  )
)
# 抽取转化后的高德坐标
content(tmp)$locations
# [1] "39.997202126977,116.347817690225"

接着,采用 leaflet 包将高德坐标系下的经纬度绘制在地图上。由于 leaflet 包默认采用 OpenStreetMap 开放街道地图服务,需要切换一下地图服务,而郎大为开发的 leafletCN[2] 正好用函数 amap() 封装了高德瓦片地图服务。如图2.2所示,定位在矿大学10楼,从百度地图转化过来,这有上百米的距离。一方面开放的高德地图 API 不支持转化高精度的经纬度坐标,另一方面转化是有损失的。

library(leaflet)
library(leafletCN)
# 绘图
leaflet(options = leafletOptions(
  minZoom = 4, maxZoom = 18, 
  zoomControl = FALSE
)) |> 
  amap() |> 
  setView(lng = 116.347817690225, lat = 39.997202126977, zoom = 18) |> 
  addCircles(
    data = data.frame(lon = 116.347817690225, lat = 39.997202126977, size = 10),
    lng = ~lon, lat = ~lat, radius = ~size, color = "red",
    fillOpacity = 0.55, stroke = T, weight = 1,
    label = htmltools::HTML("这里是 <b>学院路校区</b>, 中国矿业大学(北京)")
  )

图 2.2: 高德地图-中国矿业大学(北京)学院路校区

下面试试看,将原百度坐标不转化,直接绘制在高德地图上,如图2.3所示,可见定位到中国农业大学去了,真是失之毫厘,差之千里!

# 绘图
leaflet(options = leafletOptions(
  minZoom = 4, maxZoom = 18, 
  zoomControl = FALSE
)) |> 
  amap() |> 
  setView(lng = 116.35376693, lat = 40.00382807, zoom = 18) |> 
  addCircles(
    data = data.frame(lon = 116.35376693, lat = 40.00382807, size = 10),
    lng = ~lon, lat = ~lat, radius = ~size, color = "red",
    fillOpacity = 0.55, stroke = T, weight = 1,
    label = htmltools::HTML("这里是 <b>学院路校区</b>, 中国矿业大学(北京)")
  )

图 2.3: 高德地图-中国矿业大学(北京)

2.3 必应地图

ggmap 包相比,RgoogleMaps[3] 支持调用更多的地图服务,除了 Google 地图外,还支持必应地图,以及基于 lattice 包的可视化,借助 loa [4]latticeExtra [5]还可以支持高级的自定义,如何使用见ggmap 使用案例loa 文档latticeExtra 文档

library(RgoogleMaps) # 同时需要 RCurl
library(RCurl)

使用 Bing 地图服务也是需要 API_KEY 的,可先去 Bing 地图开发中心 用 Outlook 邮箱创建账户,然后申请免费的基础版,过程见说明。继续以地标「中国矿业大学(北京)学院路校区」为例,将百度坐标绘制在必应地图上,如图2.4所示,也定位在中国农业大学校园内了,而且显示的兴趣点 POI 少很多了!

# 保存背景地图
map_cumtb <- GetBingMap(
  center = c(lat = 40.00382807, lon = 116.35376693),
  zoom = 17, apiKey = Sys.getenv("BingMap_API_KEY"),
  verbose = 0, destfile = "cumtb-bing.png"
)
# 将点绘制到地图上
PlotOnStaticMap(map_cumtb,
  lat = 40.00382807, lon = 116.35376693,
  col = "red", FUN = points,
  pch = 19, cex = 2
)

图 2.4: 必应地图-中国矿业大学(北京)学院路校区

2.4 谷歌地图

David Kahle 开发的ggmap [6] 包在 2011 年发布在 CRAN 上,是一个基于 ggplot2 的空间可视化包,背景地图来自谷歌地图。目前,由于 Google 地图服务策略调整,需要申请 Google 地图服务的访问令牌才可以使用地图服务。 可将申请到的 GGMAP_GOOGLE_API_KEY 保存到本地文件 ~/.Renviron,以供 ggmap 后续使用。

library(ggmap)
p <- ggmap::get_googlemap(
  center = c(lon = 116.35376693, lat = 40.00382807),
  zoom = 12, size = c(640, 640), scale = 2
)
ggmap::ggmap(p)

Google 提供一种瓦片地图,即由一张张小图片拼凑起来形成一张完整的地图,就好像盖房用的瓦片,精度由瓦片的分辨率决定,如图 2.5 所示。

图 2.5: ggmap 地形图

ggmap 也可以调用多种风格样式的地图,地形图、水彩图等,如图2.6 所示。

library(ggplot2)
library(ggmap)
library(patchwork)

us <- c(left = -125, bottom = 25.75, right = -67, top = 49)
# 从 tile server 服务器上下载 Stamen Maps 数据,组成一个图片
dat1 <- get_stamenmap(
  bbox = us, zoom = 5, maptype = "toner-lite"
  )
p1 <- ggmap(dat1)
# 地形图
dat2 <- get_stamenmap(
  bbox = us, zoom = 5, maptype = "terrain"
)
p2 <- ggmap(dat2)

dat3 <- get_stamenmap(
  bbox = us, zoom = 5, maptype = "watercolor"
)
p3 <- ggmap(dat3)

dat4 <- get_stamenmap(
  bbox = us, zoom = 5, maptype = "terrain-background"
)
p4 <- ggmap(dat4)

p1 / p2 | p3 / p4

图 2.6: ggmap 几种常用背景地图

3 基础知识

与地图相关的基础知识有很多,一个简易版本可参考高德教程

3.1 网址定位

Os Keyes 开发的 R 包 rgeolocate [7] 可以解析 IP 地址,根据 IP 地址数据库提取位置信息,其内置 GeoLite2-Country.mmdb 数据支持定位到国家,是一个 MaxMind 的 GeoIP2 数据库文件。

library(rgeolocate)
# IP 地址 111.203.130.69 在中国矿业大学(北京)校区内。
maxmind(
  ips = "111.203.130.69",
  file = system.file("extdata", "GeoLite2-Country.mmdb", package = "rgeolocate"),
  fields = c(
    "continent_name", "country_code", "country_name",
    "region_name", "city_name", "city_geoname_id",
    "timezone", "longitude", "latitude"
  )
)
#   continent_name country_code country_name region_name city_name city_geoname_id
# 1           Asia           CN        China        <NA>      <NA>              NA
#   timezone longitude latitude
# 1     <NA>        NA       NA

依次是洲、国家编码简称、国家名称、区域(省级)名称、城市名称、城市代码(不确定编码体系,类似国家行政区划编码)、时区、经度和纬度。内置的数据库定位精度有限,可以去MaxMind 官网下载免费的可以定位到城市的数据库。rgeolocate 包还提供连接其它 IP 定位服务的接口,比如前面提及的 https://ipinfo.io/

ip_info('111.203.130.69')
#      city  region country              loc                                          org
# 1 Beijing Beijing      CN 39.9075,116.3972 AS4808 China Unicom Beijing Province Network
#        timezone hostname postal phone
# 1 Asia/Shanghai       NA     NA    NA

依次将城市、省份、国家、经纬度、网络服务提供商和时区都提供了。除了 rgeolocate 包,还可以使用 httr 包向 ipinfo.io 发 GET 请求,获取定位信息。

library(httr)
ip_geocode <- GET(url = "ipinfo.io")
content(ip_geocode)$loc
# [1] "39.9075,116.3972"

或者调用高德地图 IP 定位服务,定位到省、市,并返回一个两个坐标表示的矩形区域。

# 向高德地图 API 发 GET 请求
library(httr)
tmp <- GET(
    url = "https://restapi.amap.com/",
    path = "v3/ip",
    query = list(
      ip = "111.203.130.69", # IP 地址
      output = "json",       # 返回数据格式
      # 高德地图 WEB 服务 API 访问令牌
      key = Sys.getenv("AMAP_KEY")
    )
  )
# 查看全部返回信息
# content(tmp)
# 抽取位置区域
content(tmp)$rectangle
# [1] "116.0119343,39.66127144;116.7829835,40.2164962"

3.2 瓦片地图

使用高德静态地图遵守自定义地图服务协议, 应国家要求,国内的地图服务厂商必须是 GCJ-02 或 BD-09 坐标系统,用户必须拿着这两个坐标系下的空间数据,调用各大 Web 地图服务制图,比如地图瓦片,它们采用 Web 墨卡托投影,坐标参考系是 EPSG:3857 或某种再次转化加密过的 Web 墨卡托投影。

继续以地标「中国矿业大学(北京)学院路校区」为例,之前已经获得它的定位 116.347817,39.997202,调用高德静态地图服务,返回一个静态图片地址。

library(httr)
GET(
  url = "https://restapi.amap.com/",
  path = "v3/staticmap",
  query = list(
    location = "116.347817,39.997202", # 图的中心位置
    zoom = 16, # 缩放水平
    size = "600*400", # 图片长宽
    scale = 2, # 返回高清图
    labels = sprintf("%s,%s,%s,%s,%s,%s:%s",
      content = "矿大北京", # 标签内容
      font = 0, # 微软雅黑
      bold = 1, # 粗体
      fontSize = 24, # 字体大小
      fontColor = "0xFF0000",  # 字体颜色:红色
      background = "0xFFFFFF", # 字体背景:白色
      location = "116.347817,39.997202" # 标签位置
    ),
    # 高德地图 WEB 服务 API 访问令牌
    key = Sys.getenv("AMAP_KEY")
  )
)

将返回的链接输入浏览器,会得到一张如下的图片。

图 3.1: 中国矿业大学(北京)学院路校区

3.3 地理编码

地球上每一块区域都对应有一个唯一的 GeoHash 值,八位 GeoHash 码的定位精度可达400平米,更多详细的介绍可见维基百科Geohash,比起区、县、乡、镇行政单元,它是用来刻画规则的更小的网格单元,广泛用于本地生活服务场景,比如外卖、打车、配送等。geohashTools[8]可以非常高效地将 GeoHash 编码的地理区域转为经纬度坐标。

# 随意造几个位置
geohash_df <- data.frame(geohash = c(
  "wmd0k2ktr", "wmd0k2kv3", "wmd0k2kv7",
  "wmd0k2kvq", "wmd0k2mj0", "wmd0k2mj4"
))
# 转码为经纬度
dat <- geohashTools::gh_decode(geohashes = geohash_df$geohash)
# 将位置绘制在地图上
leaflet::leaflet() |> 
  leafletCN::amap() |> 
  leaflet::addCircles(lng = dat$longitude, lat = dat$latitude, radius = 3)

图 3.2: GeoHash 地理区域编码

lwgeom[9]提供函数 st_geohash() 可将经纬度坐标转为 GeoHash 码。在数据规模很大的情况下,这编码解码的过程方便聚合 GeoHash 粒度的数据,并呈现在地图上去观察数据中的空间规律,比如一些局部热点效应。

library(sf)
# Linking to GEOS 3.10.2, GDAL 3.4.2, PROJ 8.2.1; sf_use_s2() is TRUE
library(lwgeom)
# Linking to liblwgeom 3.0.0beta1 r16016, GEOS 3.10.2, PROJ 8.2.1
st_geohash(st_sfc(st_point(c(116.35688, 40.00314)), st_point(c(0, 90))), 9)
# [1] "wx4exf27p" "upbpbpbpb"

3.4 坐标系统

地球既不是圆球的也不是标准的椭球,将三维的球面投影到二维的平面有不同的方法,每一种方法都对应一种坐标转化,选定坐标原点后即构成坐标参考系统(Coordinate Reference System,简称 CRS)。maps[10]内置了一份世界地图数据,它采用 WGS 84 坐标系,此坐标系的详细描述,读者可在 R 软件 控制台运行 sf::st_crs("EPSG:4326") 获取。下面以此为例介绍,本节空间数据的操作都用 sf[11]来完成。

library(sf)
library(maps)
# st_as_sf 将 map 类转化为 sf 类
world1 <- st_as_sf(map("world", plot = FALSE, fill = TRUE))
## 检索坐标参考系统
st_crs(world1)
# Coordinate Reference System:
#   User input: EPSG:4326 
#   wkt:
# GEOGCRS["WGS 84",
#     ENSEMBLE["World Geodetic System 1984 ensemble",
#         MEMBER["World Geodetic System 1984 (Transit)"],
#         MEMBER["World Geodetic System 1984 (G730)"],
#         MEMBER["World Geodetic System 1984 (G873)"],
#         MEMBER["World Geodetic System 1984 (G1150)"],
#         MEMBER["World Geodetic System 1984 (G1674)"],
#         MEMBER["World Geodetic System 1984 (G1762)"],
#         MEMBER["World Geodetic System 1984 (G2139)"],
#         ELLIPSOID["WGS 84",6378137,298.257223563,
#             LENGTHUNIT["metre",1]],
#         ENSEMBLEACCURACY[2.0]],
#     PRIMEM["Greenwich",0,
#         ANGLEUNIT["degree",0.0174532925199433]],
#     CS[ellipsoidal,2],
#         AXIS["geodetic latitude (Lat)",north,
#             ORDER[1],
#             ANGLEUNIT["degree",0.0174532925199433]],
#         AXIS["geodetic longitude (Lon)",east,
#             ORDER[2],
#             ANGLEUNIT["degree",0.0174532925199433]],
#     USAGE[
#         SCOPE["Horizontal component of 3D system."],
#         AREA["World."],
#         BBOX[-90,-180,90,180]],
#     ID["EPSG",4326]]

sf 包提供 st_as_sf() 函数将 map 类转化为 sf 类,st_crs() 函数检索地图数据中附带的坐标参考系信息。接下来,用 st_transform() 函数转化坐标,参数 crs 指定新的坐标系统,示例里 +proj=laea 表示 Lambert Azimuthal Equal Area 投影,而 +ellps=WGS84WGS 1984,或称 EPSG:4326+lon_0=155+lat_0=-90 分别是经纬度,东经 155 度,南纬 90 度,也就是观测视角到南极去了。

# 坐标转化
world2 <- st_transform(
  x = world1,
  crs = st_crs("ESRI:102020")
)
## 检索坐标参考系统
st_crs(world2)
# Coordinate Reference System:
#   User input: ESRI:102020 
#   wkt:
# PROJCRS["South_Pole_Lambert_Azimuthal_Equal_Area",
#     BASEGEOGCRS["WGS 84",
#         DATUM["World Geodetic System 1984",
#             ELLIPSOID["WGS 84",6378137,298.257223563,
#                 LENGTHUNIT["metre",1]]],
#         PRIMEM["Greenwich",0,
#             ANGLEUNIT["Degree",0.0174532925199433]]],
#     CONVERSION["South_Pole_Lambert_Azimuthal_Equal_Area",
#         METHOD["Lambert Azimuthal Equal Area",
#             ID["EPSG",9820]],
#         PARAMETER["Latitude of natural origin",-90,
#             ANGLEUNIT["Degree",0.0174532925199433],
#             ID["EPSG",8801]],
#         PARAMETER["Longitude of natural origin",0,
#             ANGLEUNIT["Degree",0.0174532925199433],
#             ID["EPSG",8802]],
#         PARAMETER["False easting",0,
#             LENGTHUNIT["metre",1],
#             ID["EPSG",8806]],
#         PARAMETER["False northing",0,
#             LENGTHUNIT["metre",1],
#             ID["EPSG",8807]]],
#     CS[Cartesian,2],
#         AXIS["(E)",north,
#             MERIDIAN[90,
#                 ANGLEUNIT["degree",0.0174532925199433]],
#             ORDER[1],
#             LENGTHUNIT["metre",1]],
#         AXIS["(N)",north,
#             MERIDIAN[0,
#                 ANGLEUNIT["degree",0.0174532925199433]],
#             ORDER[2],
#             LENGTHUNIT["metre",1]],
#     USAGE[
#         SCOPE["Not known."],
#         AREA["Southern hemisphere."],
#         BBOX[-90,-180,0,180]],
#     ID["ESRI",102020]]

下面将两个不同坐标系统下的世界地图绘制出来,如图 3.3 所示。

library(ggplot2)
library(patchwork)
p1 <- ggplot() +
  geom_sf(data = world1)

p2 <- ggplot() +
  geom_sf(data = world2)

p1 + p2
WGS 84 和坐标系统

图 3.3: WGS 84 和坐标系统

4 空间分布

相比于上一篇可视化的文章,本篇示例有一点数据规模,数万或数十万规模的数据,本节以 R 语言复现百度地图慧眼的地理可视化示例,笔者利用开源免费的软件可以获得同样的效果。展示空间点数据的图形有散点图、热力图、蜂窝图、柱状图、飞线图等等,最常见、最常用的莫过于散点图,简单明了地呈现数据随空间位置的分布,快速透视数据的空间模式。

百度地图慧眼开源了一些地理信息可视化库,特别是Mapv,还有很多示例及使用文档。感兴趣的读者不妨看看。下面以示例中的热力柱图为例,数据下载地址2

# 示例首页 https://mapv.baidu.com/gl/examples/
curl -fLo beijing.07102610.json https://mapv.baidu.com/gl/examples/static/beijing.07102610.json

下载完成后导入到 R 环境中,并提取其中有效的数据部分,整理成 data.frame 数据类型,以便后续进一步操作。注意,这只是个普通 JSON 格式文件,而不是 GeoJSON 格式,不能用 sf::read_sf() 读取,推荐使用 Jeroen Ooms 开发的jsonlite,它效率比较高,依赖也少。

beijing <- jsonlite::fromJSON("data/mapv-data/beijing.07102610.json")
# 提取 JSON 文件中的数据
beijing <- matrix(as.numeric(beijing$result$data$bound[[1]]), ncol = 3, byrow = F)
# 设置列名便于后续操作
colnames(beijing) <- c("x", "y", "den")
# 将 matrix 转化 data.frame 数据类型
beijing <- as.data.frame(beijing)

4.1 散点图

R 语言数据操作常常离不开 data.frame,有很多空间数据也是按照一张张二维表格存储的,为了能够使用 sf 包及其生态环境,将 data.frame 类型转化为空间数据类型 sf 是非常必要的。之前,介绍过如何用 sp 包将 data.frame 转化为 sp 类型,进一步,sf 包提供的泛型函数 st_as_sf() 支持将多种数据类型转化为 sf 类型。目前支持 12 种数据类型转化,基本覆盖日常所需。

methods("st_as_sf")
#  [1] st_as_sf.data.frame*   st_as_sf.lpp*          st_as_sf.map*         
#  [4] st_as_sf.owin*         st_as_sf.ppp*          st_as_sf.ppplist*     
#  [7] st_as_sf.psp*          st_as_sf.s2_geography* st_as_sf.sf*          
# [10] st_as_sf.sfc*          st_as_sf.Spatial*      st_as_sf.SpatVector*  
# [13] st_as_sf.wk_crc*       st_as_sf.wk_rct*       st_as_sf.wk_wkb*      
# [16] st_as_sf.wk_wkt*       st_as_sf.wk_xy*       
# see '?methods' for accessing help and source code

另外,值得一提的是sfheaders[12],它可以非常方便高效地将普通数据框 data.frame 类型转化为空间数据 sf(Simple feature)类型,据其官网介绍,转化性能可以和 data.table 媲美,上游依赖很少,甚至不需要依赖 sf 包。

library(sf)
# library(sfheaders)
# beijing_sf <- sf_point(beijing, x = "x", y = "y", keep = TRUE)
# 将 data.frame 转化为 sf 类型
beijing_sf <- st_as_sf(beijing, coords = c("x", "y"))
# 指定原数据的坐标参考系
st_crs(beijing) <- 3857
# 转到日常用的经纬度坐标参考系
beijing_sf <- st_transform(beijing_sf, crs = 4326)
# 快速绘图查看数据
plot(beijing_sf["den"], pch = 19, cex = 0.2)

图 4.1: 北京市热点分布图

ggplot2 早已支持 sf 数据类型,不强调精雕细琢,绘图过程也十分简单。

library(ggplot2)
ggplot() +
  geom_sf(data = beijing_sf, aes(color = den), cex = 0.2)

图 4.2: 北京市热点分布图

rasterly 渲染 35058 多个点毫无压力,使用方式和 ggplot2 非常相近,配合 plotly 包支持渲染交互式图形,如图4.3所示。rasterly 的基本想法来自 Python 模块datashader,将数据点映射到网格中,聚合每个网格中的点得到代表点,即像素点,接着照常绘图,简而言之,连续空间离散化以近似,空间点数据转栅格数据。

library(rasterly)
# 静态散点图
rasterly(data = beijing, aes(x = x, y = y)) |> 
  rasterly_points() 
# 静态散点图:点密度、图片长宽
rasterly(data = beijing, aes(x = x, y = y, color = den), 
         plot_width = 700, plot_height = 500) |> 
  rasterly_points()
# 也可以绘制交互式的热力图
LogTransform <- function(z) {
  log(z)
}
plotly::plot_ly(beijing, x = ~x, y = ~y) |>
  add_rasterly_heatmap(
    on = ~den, # 对 den 变量取对数
    reduction_func = "max", # 聚合方式是求极大
    scaling = LogTransform, # 对数变换
    plot_width = 600, plot_height = 400
  ) |> 
  plotly::layout(
    xaxis = list(
      title = "x"
    ),
    yaxis = list(
      title = "y"
    )
  )

图 4.3: 北京市热点分布图

最后,补充介绍一下 sp 包绘制图形的过程,短期内恐还有必要。先将 data.frame 数据类型转为 sp 空间数据类型,以便调用更加高效且丰富的数据处理和可视化函数。

# 调 sp 包转化为 sp 类型
library(sp)
beijing_sp <- beijing
# 指定坐标变量
coordinates(beijing_sp) <- ~ x + y
# 指定参考系 EPSG:3857
proj4string(beijing_sp) <- CRS("EPSG:3857")
# 查看数据
summary(beijing_sp)
# Object of class SpatialPointsDataFrame
# Coordinates:
#        min      max
# x 12855679 13079460
# y  4761527  4985306
# Is projected: TRUE 
# proj4string :
# [+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m
# +nadgrids=@null +wktext +no_defs]
# Number of points: 35058
# Data attributes:
#       den       
#  Min.   : 1.00  
#  1st Qu.: 1.00  
#  Median : 2.00  
#  Mean   : 2.48  
#  3rd Qu.: 3.00  
#  Max.   :81.00
# 转化坐标参考系 EPSG:4326
beijing_sp <- spTransform(beijing_sp, CRSobj = CRS("EPSG:4326"))
# 查看转化后的数据
summary(beijing_sp)
# Object of class SpatialPointsDataFrame
# Coordinates:
#      min    max
# x 115.48 117.49
# y  39.28  40.82
# Is projected: FALSE 
# proj4string : [+proj=longlat +datum=WGS84 +no_defs]
# Number of points: 35058
# Data attributes:
#       den       
#  Min.   : 1.00  
#  1st Qu.: 1.00  
#  Median : 2.00  
#  Mean   : 2.48  
#  3rd Qu.: 3.00  
#  Max.   :81.00
# 最后绘图
spplot(beijing_sp, "den",
  colorkey = TRUE,     # 连续型图例
  alpha = 0.4,         # 散点透明度
  key.space = "right", # 图例位置
  cex = 0.3,           # 点的大小
  aspect = 0.7,        # 图形长宽比例
  scales = list(
    draw = T,
    # 去掉图形上边、右边多余的刻度线
    x = list(alternating = 1, tck = c(1, 0)),
    y = list(alternating = 1, tck = c(1, 0))
  )
)

图 4.4: 北京市热点分布图

4.2 热力图

OpenStreetMap 是开放街道地图数据,由来自世界各地的贡献者维护,但不提供免费的地图 API。使用此地图服务,不会将自己的数据上传至 OpenStreetMap 的服务器上。而 Leaflet 是开源的交互式地理可视化 JS 库。Joe Cheng 开发维护的 leaflet[13]是 JavaScript 库 Leaflet 的 R 语言接口。一个提供基础地图服务,一个提供数据渲染可视化,一个提供封装好的 R 语言接口。

图 4.5: 开源交互式 JavaScript 库 Leaflet

library(leaflet)
# 提供 addHeatmap 函数绘制热力图 
library(leaflet.extras) 
leaflet() |>
  addTiles() |>
  addHeatmap(
    data = beijing,
    lng = ~x, lat = ~y,
    intensity = ~den, # 密度
    blur = 20, max = 0.05, radius = 15
  ) |>
  addScaleBar(position = "bottomleft")

图 4.6: 北京市热点分布图

mapdeck 包将 MapBox GL 的地图服务和deck.gl的大规模可视化能力封装到一起,功能还是很强的,函数 add_heatmap() 可用来绘制热力图,数万散点毫无压力,如图4.7

mapdeck(style = mapdeck_style("dark"), pitch = 45) |> 
  add_heatmap(
    data = beijing, lat = "y", lon = "x",
    weight = "den", colour_range = hcl.colors(6)
  )

图 4.7: 北京市热点分布图

4.3 蜂窝图

除了热力图,mapdeck 调用 add_hexagon()add_screengrid() 还可以绘制蜂窝图,如图4.8

mapdeck(style = mapdeck_style("dark"), pitch = 45) |> 
  add_screengrid(
    data = beijing,
    lat = "y",
    lon = "x",
    weight = "den",
    layer_id = "screengrid_layer",
    cell_size = 5,
    opacity = 0.6,
    colour_range = hcl.colors(6)
  )

图 4.8: 北京市热点分布图

4.4 柱状图

咋一看,读者可能会疑惑,怎么柱状图也能归到地理可视化?它在蜂窝图的基础上,用柱子的高低表示密度值大小,整个地图是三维的,可拖拽。mapdeck 提供函数 add_grid() / add_hexagon() 绘制三维柱状图,如图4.9

library(mapdeck)
mapdeck(style = mapdeck_style("dark"), pitch = 45) |> 
  add_hexagon(
    data = beijing,
    lat = "y", lon = "x", elevation = "den",
    layer_id = "hex_layer",
    elevation_scale = 30,
    colour_range = hcl.colors(6)
  )

图 4.9: 北京市热点分布图

4.5 飞线图

nycflights13 [14] 包提供航班数据做飞线图,数据由 Hadley Wickham 收集自OpenFlights Airports Database,从纽约三大机场 JFK, LGA 或 EWR 的准点起飞数据。

library(nycflights13)
data("flights") # 航班
data("airports") # 机场

提取2013年1月1日的航班数据,出发地 origin 和目的地 dest。数据集 airports 包含 FAA 机场代码,经纬度(经纬度精确到小数点后6位,坐标参考系 EPSG:4326)等信息。

# 提取部分航班数据
sub_flights <- subset(
  x = flights, subset = year == 2013 & month == 1 & day == 1,
  select = c("origin", "dest", "time_hour")
)
# 统计航班架次
sub_flights <- aggregate(formula = time_hour ~ origin + dest, data = sub_flights, FUN = length)
# 准备机场位置信息
sub_airports <- subset(
  x = airports, select = c("faa", "lon", "lat")
)

关联机场位置信息,获取起飞机场和落地机场的经纬度信息。

# 起飞机场
tmp <- merge(sub_flights, sub_airports, by.x = "origin", by.y = "faa")
colnames(tmp) <- c("origin", "dest", "cnt", "origin_lon", "origin_lat")
# 落地机场
tmp <- merge(tmp, sub_airports, by.x = "dest", by.y = "faa")
colnames(tmp) <- c(
  "origin", "dest", "cnt", "origin_lon", "origin_lat",
  "dest_lon", "dest_lat"
)

mapdeck 提供 add_greatcircle() 图层绘制飞线图。

mapdeck(style = "mapbox://styles/mapbox/dark-v9") |> 
  add_greatcircle(
    data = tmp,
    layer_id = "arc_layer",
    origin = c("origin_lon", "origin_lat"),
    destination = c("dest_lon", "dest_lat"),
    stroke_from = "cnt",
    stroke_width = 3, # 弧线宽度
    palette = "viridis", # 调色板 "plasma"
    legend = TRUE
  ) |>  
  mapdeck_view(
    # 视角位置
    location = c(-110, 48),
    # 设置缩放水平
    zoom = 2,
    # 俯仰角
    pitch = 45
  )

图 4.10: 2013年1月1日纽约机场出发的航班

5 专题地图

Thematic Maps (or Statistical Maps) 专题地图或统计地图,重点在于呈现一个或多个地理属性(变量)的空间模式,属于制图学 Cartography 范畴。一种常见的 Thematic Maps 是 Choropleth map, 地图上每个单元(或数据收集的单元,比如州、郡、县)用色彩填充表示属性的大小。专题地图具体形式可以有比例符号图(气泡图),散点图,迁徙/流向图等。据 Michael Friendly 等考证,最早的 Choropleth Map 可追溯到 1819 年,一位叫 Baron Pierre Charles Dupin 的法国人,统计法国各个地区的文盲分布和比例,用从黑到白的颜色表示文盲比例的高低。

一个现代化的示例图5.1来自美国人口普查局官网,展示2020年美国各个城镇的人口密度及相关信息,采用 Tableau 制作而成。

图 5.1: 2020年美国各个城镇的人口密度

下面引用 leaflet官方文档中的示例—2011 年美国各个州的人口密度。数据存储以GeoJSON 格式存储在文件 us-states.geojson里,它是矢量多边形数据,坐标参考系是 WGS 84,包含美国各个州的边界 geometry,各州人口密度数据 density(每平方英里的人口数)。us-states.geojson的数据源头是 2011 年美国人口普查局。顺便一说,2020年的最新数据可从维基百科文章获取。

笔者在此示例的基础上有几个改进点:

  1. 示例中调用 Mapbox 地图的方法不可用,可用 leaflet 自带的 OpenStreetMap 瓦片地图替换,或用函数 addTiles() 自定义地图瓦片的方式调用 Mapbox 地图;
  2. 示例中使用geojsonio包读取 GeoJSON 格式的地图数据文件性能差,且上游过时的依赖很多,改用 sf 包读取,性能高、速度快;
  3. 添加一些数据集和代码的中文描述,补充一些细节说明;
  4. 空间数据处理从 sp 升级到 sf,使得整个数据可视化过程仅依赖 sfleaflet 包,达到了稳定、高效和可靠。
library(sf)
# 读取数据
states <- st_read("data/us-states.geojson")
# Reading layer `us-states' from data source 
#   `/Users/xiangyun/Desktop/xiangyun/content/post/2022-01-15-spatial-data-visualization/data/us-states.geojson' 
#   using driver `GeoJSON'
# Simple feature collection with 52 features and 3 fields
# Geometry type: MULTIPOLYGON
# Dimension:     XY
# Bounding box:  xmin: -188.9 ymin: 17.93 xmax: -65.63 ymax: 71.35
# Geodetic CRS:  WGS 84

查看数据采用的坐标参考系。

st_crs(states)
# Coordinate Reference System:
#   User input: WGS 84 
#   wkt:
# GEOGCRS["WGS 84",
#     DATUM["World Geodetic System 1984",
#         ELLIPSOID["WGS 84",6378137,298.257223563,
#             LENGTHUNIT["metre",1]]],
#     PRIMEM["Greenwich",0,
#         ANGLEUNIT["degree",0.0174532925199433]],
#     CS[ellipsoidal,2],
#         AXIS["geodetic latitude (Lat)",north,
#             ORDER[1],
#             ANGLEUNIT["degree",0.0174532925199433]],
#         AXIS["geodetic longitude (Lon)",east,
#             ORDER[2],
#             ANGLEUNIT["degree",0.0174532925199433]],
#     ID["EPSG",4326]]

查看地图数据的边界信息。

st_bbox(states)
#    xmin    ymin    xmax    ymax 
# -188.90   17.93  -65.63   71.35

做一点说明,西经 -188.90491,也就是东经 360 - 188.90491 = 180 - 8.90491 = 171.0951,西经 -65.62680 至西经 -188.90491 跨越范围 188.90491 - 65.62680 = 123.2781。 中心位置经度 ((-65.62680) + (-188.90491) )/ 2 = -127.2659,纬度 (17.92956 + 71.35163)/2 = 44.64059。此中心位置不在美国大陆。

本初子午线定义为 0 度经线,是经过英国格林尼治天文台的一条经线,向东、向西分别定义为东经、西经。东经 180 度 和西经 180 度是同一条经线,穿越新西兰罗斯属地,也穿越美国阿拉斯加州。美国国土东西跨度很大,跨越了东(西)经 180 度。

5.1 leaflet

library(leaflet)
# 人口密度分段
bins <- c(0, 10, 20, 50, 100, 200, 500, 1000, Inf)
# 构造调色板
pal <- colorBin("YlOrRd", domain = states$density, bins = bins)
# 准备悬浮提示
labels <- sprintf(
  "<strong>%s</strong><br/>%g people / mi<sup>2</sup>",
  states$name, states$density
) |>  lapply(htmltools::HTML)

# 绘图
leaflet(states) |> 
  setView(lng = -96, lat = 37.8, zoom = 4) |> 
  # 添加默认的 OSM 瓦片服务
  addTiles() |> 
  addPolygons(
    color = "white", # 边界线颜色
    opacity = 1,     # 颜色透明度
    weight = 2,      # 边界线宽度
    fillOpacity = 0.6,   # 填充色透明度
    fillColor = ~ pal(density),  # 填充色
    label = labels
  ) |> 
  addLegend(
    pal = pal, 
    values = ~density,
    opacity = 0.7, 
    title = "人口密度",
    position = "bottomright"
  )

图 5.2: leaflet 包专题地图

LeafLet JS 在专题地图choropleth示例里调用了 Mapbox 瓦片服务和地图风格。下面在函数 addTiles() 里替换瓦片服务 URL 模版路径,leaflet 也可用上 Mapbox 瓦片地图。

leaflet(states) |> 
  setView(lng = -96, lat = 37.8, zoom = 4) |> 
  addTiles(urlTemplate = sprintf(
    paste(
      "https://api.mapbox.com/styles/v1/mapbox/light-v9",
      "tiles/{z}/{x}/{y}?access_token=%s",
      sep = "/"
    ),
    Sys.getenv("MAPBOX_TOKEN") # 准备个人访问令牌
  ), attribution = sprintf(
    paste(
      "Map data &copy; <a href='%s'>OpenStreetMap</a>",
      "contributors, Imagery © <a href='%s'>Mapbox</a>"
    ),
    "https://www.openstreetmap.org/copyright",
    "https://www.mapbox.com/"
  )) |> 
  addPolygons(
    color = "white", # 边界线颜色
    opacity = 1, # 颜色透明度
    weight = 2, # 边界线宽度
    fillOpacity = 0.6, # 填充色透明度
    fillColor = ~ pal(density), # 填充色
    label = labels
  ) |> 
  addLegend(
    pal = pal,
    values = ~density,
    opacity = 0.7,
    title = "人口密度",
    position = "bottomright"
  )

图 5.3: leaflet 包专题地图

借助leaflet-providers,Leaflet 已经支持很多瓦片服务,除了以上两种,比如夜景图5.4

library(leaflet)
leaflet() |> 
  addProviderTiles("NASAGIBS.ViirsEarthAtNight2012") |> 
  addMarkers(
    lng = 116.35376693,
    lat = 40.00382807,
    popup = "中国矿业大学(北京)学院路校区"
  )

图 5.4: leaflet 包调 NASA 瓦片服务

5.2 mapdeck

leaflet 要求类似,mapdeck 也要求空间数据的坐标参考系是 EPSG:4326

library(mapdeck)
states <- transform(states, density_log10 = log10(density))
mapdeck() |> 
  add_polygon(
    data = states,
    fill_colour = "density_log10",
    fill_opacity = 0.8,
    palette = "plasma",
    legend = TRUE
  )

图 5.5: mapdeck 包专题地图

5.3 sf

地理单元的编码具有唯一性,即一套编码体系下,同一块地方不能有两个编码值,可以将其视为主键,则每一块区域上的人口密度就是属性值。

plot(states["density"], logz = TRUE)

图 5.6: sf 包专题地图

5.4 ggplot2

library(ggplot2)
ggplot() +
  geom_sf(data = states, aes(geometry = geometry, fill = density), colour = NA) +
  scale_fill_gradientn(
    trans = "log10", colors = sf.colors(10),
    guide = guide_colourbar(
      barwidth = 25, barheight = 0.4,
      title.position = "left"
    )
  ) +
  theme(
    panel.grid = element_line(colour = "gray90"),
    legend.position = "top",
    panel.background = element_blank()
  )

图 5.7: ggplot2 包专题地图

6 本文小结

空间数据最核心的概念还是几何元素的表示,关系及其应用。 结合地图服务绘图需要注意坐标参考系统及其转化关系 [15],同时需要验证/注意地图服务提供的数据的准确性。

R 语言社区在时空数据分析、可视化方面有很多工具,继 sp [16] 之后,Edzer Pebesma 开发了 sf [17],提供更加高效的矢量空间数据处理方式。紧接着,Robert J. Hijmans 也将处理栅格数据的 raster [18] 升级到 terra [19],提供性能强劲且向后兼容性极好的函数接口。satellite[20]landsat[21] 可以处理卫星遥感数据,rasterbc [22] 内置 2001-2018 年加拿大英属哥伦比亚省 raster 格式的栅格数据,用于森林生态学研究。

相比于 ggplot2 [23]lattice [24] 是被严重低估的数据可视化包,性能不输 ggplot2,而且更加稳定,与上一代空间数据处理框架 spraster 有很好的集成。ggspatial 提供很多针对空间数据可视化的定制,比如指北针、比例尺等。

cartography 除了基本的维护,不再添加新的功能,推荐读者转移到 mapsf 上。mapsf [25] 基于 Base R 图形系统将 sf 数据对象绘制在地图上,获得出版级的效果,支持比例气泡图、专题地图和拓扑地图等。tmap [26] 功能与之类似,使用方式和 ggplot2 提供的图形语法一致。

数据产品很多都基于 Web 呈现,交互式图形成为必须的组件,leaflet [13] 在交互式地理可视化方面是比较成熟的,中小规模数据集下,性能还不错。 mapdeck 包站在 deck.gl 的肩膀上渲染初具规模的空间数据毫无压力。mapview [27]意在提供快速地交互式探索空间数据的能力,支持 leafletmapdeck 渲染方法。而mapedit [28]提供空间数据交互式编辑能力。

7 环境信息

在 RStudio IDE 内编辑本文的 R Markdown 源文件,用 blogdown [29] 构建网站,Hugo 渲染 knitr 之后的 Markdown 文件,得益于 blogdown 对 R Markdown 格式的支持,图、表和参考文献的交叉引用非常方便,省了不少文字编辑功夫。文中使用了多个 R 包,为方便复现本文内容,下面列出详细的环境信息:

xfun::session_info(packages = c(
  "knitr", "rmarkdown", "blogdown",
  "httr", "jsonlite", "nycflights13",
  "ggmap", "ggplot2", "patchwork", "spData",
  "leaflet", "leafletCN", "leaflet.extras",
  "baidumap", "RgoogleMaps", "mapdeck",
  "geohashTools", "lwgeom", "rgeolocate",
  "lattice", "sf", "terra", "stars",
  "sp", "maps", "raster", "rasterly"
), dependencies = FALSE)
# R version 4.2.1 (2022-06-23)
# Platform: x86_64-apple-darwin17.0 (64-bit)
# Running under: macOS Big Sur ... 10.16
# 
# Locale: en_US.UTF-8 / en_US.UTF-8 / en_US.UTF-8 / C / en_US.UTF-8 / en_US.UTF-8
# 
# Package version:
#   baidumap_0.2.2       blogdown_1.10        geohashTools_0.3.1   ggmap_3.0.0         
#   ggplot2_3.3.6        httr_1.4.3           jsonlite_1.8.0       knitr_1.39          
#   lattice_0.20-45      leaflet_2.1.1        leaflet.extras_1.0.0 leafletCN_0.2.1     
#   lwgeom_0.2-8         mapdeck_0.3.4        maps_3.4.0           nycflights13_1.0.2  
#   patchwork_1.1.1      raster_3.5.21        rasterly_0.2.0       rgeolocate_1.4.2    
#   RgoogleMaps_1.4.5.3  rmarkdown_2.14       sf_1.0-7             sp_1.5-0            
#   spData_2.0.1         stars_0.5.5          terra_1.5.34        
# 
# Pandoc version: 2.18
# 
# Hugo version: 0.101.0

8 参考文献

[1]
[2]
[3]
Loecher, M. and Ropkins, K. (2015). RgoogleMaps and loa: Unleashing R graphics power on map tiles. Journal of Statistical Software 63 1–8.
[4]
[5]
[6]
Kahle, D. and Wickham, H. (2013). ggmap: Spatial visualization with ggplot2. The R Journal 5 144–61.
[7]
Keyes, O., Schmidt, D., Robinson, D., Davis, C., Rudis, B., Maxmind, Inc., Gloor, P. and IP2Location.com. (2021). Rgeolocate: IP address geolocation.
[8]
[9]
[10]
Brownrigg, R. (2021). Maps: Draw geographical maps.
[11]
Pebesma, E. (2022). Sf: Simple features for r.
[12]
[13]
Cheng, J., Karambelkar, B. and Xie, Y. (2022). Leaflet: Create interactive web maps with the JavaScript leaflet library.
[14]
[15]
[16]
Pebesma, E. J. and Bivand, R. S. (2005). Classes and methods for spatial data in R. R News 5 9–13.
[17]
Pebesma, E. J. (2018). Simple Features for R: Standardized Support for Spatial Vector Data. The R Journal 10 439–46.
[18]
[19]
Hijmans, R. J. (2022). Terra: Spatial data analysis.
[20]
Nauss, T., Meyer, H., Appelhans, T. and Detsch, F. (2021). Satellite: Handling and manipulating remote sensing data.
[21]
Goslee, S. C. (2011). Analyzing remote sensing data in R: The landsat package. Journal of Statistical Software 43 1–25.
[22]
[23]
Wickham, H. (2022). ggplot2: Elegant graphics for data analysis. Springer-Verlag New York.
[24]
Sarkar, D. (2008). lattice: Multivariate data visualization with R. Springer-Verlag, New York.
[25]
Giraud, T. (2022). Mapsf: Thematic cartography.
[26]
Tennekes, M. (2018). tmap: Thematic maps in R. Journal of Statistical Software 84 1–39.
[27]
Appelhans, T., Detsch, F., Reudenbach, C. and Woellauer, S. (2022). Mapview: Interactive viewing of spatial data in r.
[28]
Appelhans, T., Russell, K. and Busetto, L. (2020). Mapedit: Interactive editing of spatial data in r.
[29]
Xie, Y., Hill, A. P. and Thomas, A. (2017). blogdown: Creating websites with R markdown. Chapman; Hall/CRC, Boca Raton, Florida.

  1. ipinfo 工具是开源可商用的,由前 Facebook 工程师 Ben Dowling 于 2013 年创建,后来发展成 IPinfo 公司,专门提供 IP 定位服务,服务的公司包括腾讯、戴尔、耐克等。↩︎

  2. 查看示例可以看到源数据的地址, 其它示例数据可以通过类似的方法获取,比如点图层中的全国点效果示例:

    curl -fLo chinalocation.json https://mapv.baidu.com/gl/examples/data/chinalocation.json
    ↩︎