You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

354 lines
18 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 39 | 实战(三):如何实现地理信息的可视化?
你好,我是月影。
前段时间,我们经常能看到新冠肺炎的疫情地图。这些疫情地图非常直观地呈现了世界上不同国家和地区,一段时间内的新冠肺炎疫情进展,能够帮助我们做好应对疫情的决策。实际上,这些疫情地图都属于地理位置信息可视化,而这类信息可视化的主要呈现方式就是地图。
在如今的互联网领域地理信息可视化应用非常广泛。除了疫情地图我们平时使用外卖订餐、春运交通、滴滴打车这些App中都有地理信息可视化的实现。
那地理信息可视化该如何实现呢?今天,我们就通过一个疫情地图的实现,来讲一讲地理信息可视化该怎么实现。
假设我们要使用世界地图的可视化来呈现不同国家和地区从2020年1月22日到3月19日这些天的新冠肺炎疫情进展。我们具体该怎么做呢主要有四个步骤分别是准备数据、绘制地图、整合数据和更新绘制方法。下面我们一一来看。
## 步骤一:准备数据
新冠肺炎的官方数据在WHO网站上每天都会更新我们可以直接找到2020年1月22日到3月19日的数据将这些数据收集和整理成一份JSON文件。这份JSON文件的内容比较大我把它放在Github上了你可以去[Github仓库](https://github.com/akira-cn/graphics/blob/master/covid-vis/assets/data/covid-data.json)查看这份数据。
有了JSON数据之后我们就可以将这个数据和世界地图上的国家一一对应。那接下来的任务就是准备世界地图想要绘制一份世界地图我们也需要有世界地图的地理数据这也是一份JSON文件。
地理数据通常可以从开源社区中获取公开数据,或者从相应国家的测绘部门获取当地的公开数据。这次用到的世界地图的数据,我们是通过开源社区获得的。
一般来说地图的JSON文件有两种数据格式一种是GeoJSON另一种是TopoJSON。其中GeoJSON是基础格式它包含了描述地图地理信息的坐标数据。举个简单的例子
```
{
"type":"FeatureCollection",
"features": [
{
"type":"Feature",
"geometry":{
"type":"Polygon",
"coordinates":
[
[[117.42218831167838,31.68971206252246],
[118.8025942451759,31.685801564127132],
[118.79961418869482,30.633841626314336],
[117.41920825519742,30.637752124709664],
[117.42218831167838,31.68971206252246]]
]
},
"properties":{"Id":0}
}
]
}
```
上面的代码就是一个合法的GeoJSON数据它定义了一个地图上的多边形区域坐标是由四个包含了经纬度的点组成的代码中一共是五个点但是首尾两个点是重合的
那什么是TopoJSON格式呢TopoJSON格式就是GeoJSON格式经过压缩之后得到的它通过对坐标建立索引来减少冗余数据压缩能够大大减少JSON文件的体积。
因为这节课的重点主要是地理信息的可视化绘制而GeoJSON和TopJSON文件格式的具体规范又比较复杂不是我们课程的重点所以我就不详细来讲了。如果你有兴趣进一步学习可以参考我在课后给出的资料。
这节课我们直接使用我准备好的两份世界地图的JSON数据就可以了一份是[GeoJSON数据](https://github.com/akira-cn/graphics/blob/master/convid-vis/assets/data/world-geojson.json),一份是[TopoJSON数据](https://github.com/akira-cn/graphics/blob/master/convid-vis/assets/data/world-topojson.json)。接下来,我们会分别来讲怎么使用它们来绘制地图。
## 步骤二:绘制地图
将数据绘制成地图的方法有很多种我们既可以用Canvas2D、也可以用SVG还可以用WebGL。除了用WebGL相对复杂用Canvas2D和SVG都比较简单。为了方便你理解我选择用比较简单的Canvas2D来绘制地图。
首先我们将GeoJSON数据中coordinates属性里的经纬度信息转换成画布坐标这个转换被称为地图投影。实际上地图有很多种投影方式但最简单的方式是**墨卡托投影**也叫做等圆柱投影。它的实现思路就是把地球从南北两极往外扩先变成一个圆柱体再将世界地图看作是贴在圆柱侧面的曲面经纬度作为x、y坐标。最后我们再把圆柱侧面展开经纬度自然就被投影到平面上了。
[![](https://static001.geekbang.org/resource/image/cc/f4/cc45e95168fbfaf5bb76df694c13e3f4.jpg?wh=1372*599 "墨卡托投影")](https://zh.wikipedia.org/wiki/%E9%BA%A5%E5%8D%A1%E6%89%98%E6%8A%95%E5%BD%B1%E6%B3%95)
墨卡托投影是最常用的投影方式,因为它的坐标转换非常简单,而且经过墨卡托投影之后的地图中,国家和地区的形状与真实的形状仍然保持一致。但它也有缺点,由于是从两极往外扩,因此高纬度国家的面积看起来比实际的面积要大,并且维度越高面积偏离的幅度越大。
在地图投影之前,我们先来明确一下经纬度的基本概念。经度的范围是-180度到180度负数代表西经正数代表东经。纬度的范围是-90度到90度负数代表南纬正数代表北纬。
接下来我们就可以将经纬度按照墨卡托投影的方式转换为画布上的x、y坐标。对应的经纬度投影如下图所示。
![](https://static001.geekbang.org/resource/image/b0/c4/b06a496725cdff471bf531ab3721ddc4.jpg?wh=1128*525)
注意精度范围是360度而维度范围是180度而且因为y轴向下所以计算y需要用1.0减一下。
所以对应的换算公式如下:
```
x = width * (180 + longitude) / 360;
y = height * (1.0 - (90 + latitude) / 180); // Canvas坐标系y轴朝下
```
其中longitude是经度latitude是纬度width是Canvas的宽度height是Canvas的高度。
那有了换算公式,我们将它封装成投影函数,代码如下:
```
// 将geojson数据用墨卡托投影方式投影到1024*512宽高的canvas上
const width = 1024;
const height = 512;
function projection([longitude, latitude]) {
const x = width * (180 + longitude) / 360;
const y = height * (1.0 - (90 + latitude) / 180); // Canvas坐标系y轴朝下
return [x, y];
}
```
有了投影函数之后我们就可以读取和遍历GeoJSON数据了。
我们用fetch来读取JSON文件将它包含地理信息的字段取出来。根据GeoJSON规范这个字段是features字段类型是数组然后我们通过forEach方法遍历这个数组。
```
(async function () {
const worldData = await (await fetch('./assets/data/world-geojson.json')).json();
const features = worldData.features;
features.forEach(({geometry}) => {
...遍历数据
...进行投影转换
...进行绘制
});
}();
```
在forEach迭代的时候我们可以拿到features数组中每一个元素里的geometry字段这个字段中包含有coordinates数组coordinates数组中的值就是经纬度值我们可以对这些值进行投影转换最后调用drawPoints将这个数据画出来。绘制过程十分简单你直接看下面的代码就可以理解。
```
function drawPoints(ctx, points) {
ctx.beginPath();
ctx.moveTo(...points[0]);
for(let i = 1; i < points.length; i++) {
ctx.lineTo(...points[i]);
}
ctx.fill();
}
```
完整的代码我放在了[GitHub仓库](https://github.com/akira-cn/graphics/blob/master/convid-vis/mercator.html)中,你可以下载到本地运行。这里,我直接把运行的结果展示给你看。
![](https://static001.geekbang.org/resource/image/37/c7/373977623609e83d8911f679240d7dc7.jpg?wh=1000*490)
以上就是利用GeoJSON数据绘制地图的全过程。这个过程非常简单我们只需要将coordinate数据进行投影然后根据投影的坐标把轮廓绘制出来就可以了。但是GeoJSON数据通常比较大如果我们直接在Web应用中使用有些浪费带宽也可能会导致网络加载延迟所以使用TopoJSON数据是一个更好的选择。
举个例子同样的世界地图数据GeoJSON格式数据有251KB而经过了压缩的TopoJSON数据只有84KB体积约为原来的1/3。
尽管体积比GeoJSON数据小了不少但是TopoJSON数据经过了复杂的压缩之后我们在使用的时候还需要对它解压把它变成GeoJSON数据。可是如果我们自己写代码去解压实现起来比较复杂。好在我们可以采用现成的工具对它进行解压。这里我们可以使用GitHub上的[TopoJSON官方仓库](https://github.com/topojson/topojson)的JavaScript模块来处理TopoJSON数据。
这个转换简单到只用一行代码就可以完成,转换完成之后,我们就可以用同样的方法将世界地图绘制出来了。具体的转换过程我就不多说了,你可以自己试一试。转换代码如下:
```
const countries = topojson.feature(worldData, worldData.objects.countries);
```
## 步骤三:整合数据
有了世界地图之后下一步就是将疫情的JSON数据整合进地图数据里面。
在GeoJSON或者TopoJSON解压后的countries数据中除了用geometries字段保存地图的地区信息外还用properties字段来保存了其他的属性。在我们这一份地图数据中properties只有一个name属性对应着不同国家的名字。
我们打开[TopoJSON文件](https://raw.githubusercontent.com/akira-cn/graphics/master/convid-vis/assets/data/world-topojson.json)就可以看到在contries.geometries下的properties属性中有一个name属性对应国家的名字。
![](https://static001.geekbang.org/resource/image/33/b6/331fb9c48b9c6245446190a9f19078b6.jpeg?wh=1920*1080)
这个时候,我们再打开[疫情的JSON数据](https://raw.githubusercontent.com/akira-cn/graphics/master/convid-vis/assets/data/convid-data.json)我们会发现疫情数据中的contry属性和GeoJSON数据里面的国家名称是一一对应的。
![](https://static001.geekbang.org/resource/image/a8/70/a8975be77a6e8f3bdde2450c198e3f70.jpeg?wh=1920*1080)
这样我们就可以建立一个数据映射关系将疫情数据中的每个国家的疫情数据直接写入到GeoJSON数据的properties字段里面。
接着,我们增加一个数据映射函数:
```
function mapDataToCountries(geoData, convidData) {
const convidDataMap = {};
convidData.dailyReports.forEach((d) => {
const date = d.updatedDate;
const countries = d.countries;
countries.forEach((country) => {
const name = country.country;
convidDataMap[name] = convidDataMap[name] || {};
convidDataMap[name][date] = country;
});
});
geoData.features.forEach((d) => {
const name = d.properties.name;
d.properties.convid = convidDataMap[name];
});
}
```
在这个函数里我们先将疫情数据的数组转换成以国家名为key的Map然后将它写入到TopoJSON读取出的Geo数据对象里。
最后我们直接读取两个JSON数据调用这个数据映射函数就完成了数据整合。
```
const worldData = await (await fetch('./assets/data/world-topojson.json')).json();
const countries = topojson.feature(worldData, worldData.objects.countries);
const convidData = await (await fetch('./assets/data/convid-data.json')).json();
mapDataToCountries(countries, convidData);
```
因为整合好的数据比较多,所以我只在这里列出一个国家的示例数据:
```
{
"objects": {
"countries": {
"type": "GeometryCollection",
"geometries": [{
"arcs": [
[0, 1, 2, 3, 4, 5]
],
"type": "Polygon",
"properties": {
"name": "Afghanistan",
"convid": {
"2020-01-22": {
"confirmed": 1,
"recovered": 0,
"death": 0,
},
"2020-01-23": {
...
},
...
}
}
},
...
```
## 步骤四:将数据与地图结合
将全部数据整合到地理数据之后,我们就可以将数据与地图结合了。在这里,我们设计用不同的颜色来表示疫情的严重程度,填充地图,确诊人数越多的区域颜色越红。要实现这个效果,我们先要创建一个确诊人数和颜色的映射函数。
我把无人感染到感染人数超过10000人划分了7个等级每个等级用不同的颜色表示
* 若该地区无人感染,渲染成 #3ac 颜色
* 若该地区感染人数小于10渲染成rgb(250, 247, 171)色
* 若该地区感染人数10~99人渲染成rgb(255, 186, 66)色
* 若该地区感染人数100~499人渲染成rgb(234, 110, 41)色
* 若该地区感染人数500~999人渲染成rgb(224, 81, 57)色
* 若该地区感人人数1000~9999人渲染成rgb(192, 50, 39)色
* 若该地区感染人数超10000人渲染成rgb(151, 32, 19)色
对应的代码如下:
```
function mapColor(confirmed) {
if(!confirmed) {
return '#3ac';
}
if(confirmed < 10) {
return 'rgb(250, 247, 171)';
}
if(confirmed < 100) {
return 'rgb(255, 186, 66)';
}
if(confirmed < 500) {
return 'rgb(234, 110, 41)';
}
if(confirmed < 1000) {
return 'rgb(224, 81, 57)';
}
if(confirmed < 10000) {
return 'rgb(192, 50, 39)';
}
return 'rgb(151, 32, 19)';
}
```
然后我们在绘制地图的代码里根据确诊人数设置Canvas的填充信息
```
function drawMap(ctx, countries, date) {
date = formatDate(date); // 转换日期格式
countries.features.forEach(({geometry, properties}) => {
... 读取当前日期下的确诊人数
ctx.fillStyle = mapColor(confirmed); // 映射成地图颜色并设置到Canvas上下文
... 执行绘制
});
```
我们先把data参数设为2020-01-22这样一来我们就绘制出了2020年1月22日的疫情地图。
![](https://static001.geekbang.org/resource/image/d7/65/d79fe31cc6b92340077ccb5e1f085865.jpg?wh=1000*491 "2020年1月22日疫情地图")
可是疫情的数据每天都会更新如果想让疫情地图随着日期自动更新我们该怎么做呢我们可以给地图绘制过程加上一个定时器这样我们就能得到一个动态的疫情地图了它会自动显示从1月22日到当前日期疫情变化。这样我们就能看到疫情随时间的变化了。
```
const startDate = new Date('2020/01/22');
let i = 0;
const timer = setInterval(() => {
const date = new Date(startDate.getTime() + 86400000 * (++i));
drawMap(ctx, countries, date);
if(date.getTime() + 86400000 > Date.now()) {
clearInterval(timer);
}
}, 100);
drawMap(ctx, countries, startDate);
```
![](https://static001.geekbang.org/resource/image/68/92/68676fbffedcac02dfa178d603025292.gif?wh=432*216)
## 要点总结
这节课,我们讲了实现地理信息可视化的通用步骤,一共可以分为四步,我们一起来回顾一下。
第一步是准备数据包括地图数据和要可视化的数据。地图数据有GeoJSON和TopoJSON两个规范。相比较而言TopoJSON数据格式经过了压缩体积会更小比较适合Web应用。
第二步是绘制地图。要绘制地图,我们需要将地理信息中的坐标信息投影到地图上,最简单的投影方式是使用墨卡托投影。
第三步是整合数据,我们要把可视化数据和地图的地理数据集成到一起,这一步我们可以通过定义数据映射函数来实现。
最后一步,就是将数据与地图结合,根据整合后的数据结合地图完成最终的图形绘制。
总的来说无论我们要实现多么复杂的地理信息可视化地图核心的4个步骤是不会变的只不过其中的每一步我都可以替换具体的实现方式比如我们可以使用其他的投影方式来代替墨卡托投影来绘制不同形状的地图。
## 课后练习
1. 我们今天选择使用Canvas来绘制地图是因为它使用起来十分方便。其实使用SVG绘制地图也很方便你能试着改用SVG来实现今天的疫情地图吗这和使用Canvas有什么共同点和不同点
2. 我们今天使用的墨卡托投影是最简单的投影方法它的缺点是让高纬度下的国家看起来比实际的要大很多。你能试着使用D3.js的[d3-geo](https://github.com/d3/d3-geo)模块中提供的其他投影方式来实现地图吗?
3.如果 我们要增加交互,让鼠标移动到某个国家区域的时候,这个区域高亮,并且显示国家名称、疫情确诊数、治愈数以及死亡数,这该怎么处理呢?你可以尝试增加这样的交互功能,来完善我们的地图应用吗?
好啦,今天的地理信息可视化实战就到这里了。欢迎你把实现的地图作品放在留言区,也欢迎把这节课转发出去,我们下节课见!
* * *
## 源码
\[1\] [新冠肺炎数据](https://github.com/akira-cn/graphics/blob/master/convid-vis/assets/data/convid-data.json)
\[2\] [GeoJSON数据](https://github.com/akira-cn/graphics/blob/master/covid-vis/assets/data/world-geojson.json)
\[3\] [TopoJSON数据](https://github.com/akira-cn/graphics/blob/master/covid-vis/assets/data/world-topojson.json)
\[4\] 完整的示例代码见[GitHub仓库](https://github.com/akira-cn/graphics/blob/master/covid-vis/mercator.html)
## 推荐阅读
\[1\] [GeoJSON标准格式学习](https://www.jianshu.com/p/852d7ad081b3)
\[2\] \[GeoJSON和TopoJSON\][reference\_end](https://blog.xcatliu.com/2015/04/24/geojson_and_topojson/)
\[3\] [GeoJSON规范](https://geojson.org/geojson-spec.html)
\[4\] [TopoJSON规范](https://github.com/topojson/topojson-specification/blob/master/README.md)