# 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)