354 lines
18 KiB
Markdown
354 lines
18 KiB
Markdown
|
# 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)
|
|||
|
|