你好,我是月影。

前段时间,我们经常能看到新冠肺炎的疫情地图。这些疫情地图非常直观地呈现了世界上不同国家和地区,一段时间内的新冠肺炎疫情进展,能够帮助我们做好应对疫情的决策。实际上,这些疫情地图都属于地理位置信息可视化,而这类信息可视化的主要呈现方式就是地图。

在如今的互联网领域,地理信息可视化应用非常广泛。除了疫情地图,我们平时使用外卖订餐、春运交通、滴滴打车,这些App中都有地理信息可视化的实现。

那地理信息可视化该如何实现呢?今天,我们就通过一个疫情地图的实现,来讲一讲地理信息可视化该怎么实现。

假设,我们要使用世界地图的可视化,来呈现不同国家和地区,从2020年1月22日到3月19日这些天的新冠肺炎疫情进展。我们具体该怎么做呢?主要有四个步骤,分别是准备数据、绘制地图、整合数据和更新绘制方法。下面,我们一一来看。

步骤一:准备数据

新冠肺炎的官方数据在WHO网站上每天都会更新,我们可以直接找到2020年1月22日到3月19日的数据,将这些数据收集和整理成一份JSON文件。这份JSON文件的内容比较大,我把它放在Github上了,你可以去Github仓库查看这份数据。

有了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数据,一份是TopoJSON数据。接下来,我们会分别来讲怎么使用它们来绘制地图。

步骤二:绘制地图

将数据绘制成地图的方法有很多种,我们既可以用Canvas2D、也可以用SVG,还可以用WebGL。除了用WebGL相对复杂,用Canvas2D和SVG都比较简单。为了方便你理解,我选择用比较简单的Canvas2D来绘制地图。

首先,我们将GeoJSON数据中,coordinates属性里的经纬度信息转换成画布坐标,这个转换被称为地图投影。实际上,地图有很多种投影方式,但最简单的方式是墨卡托投影,也叫做等圆柱投影。它的实现思路就是把地球从南北两极往外扩,先变成一个圆柱体,再将世界地图看作是贴在圆柱侧面的曲面,经纬度作为x、y坐标。最后,我们再把圆柱侧面展开,经纬度自然就被投影到平面上了。

墨卡托投影是最常用的投影方式,因为它的坐标转换非常简单,而且经过墨卡托投影之后的地图中,国家和地区的形状与真实的形状仍然保持一致。但它也有缺点,由于是从两极往外扩,因此高纬度国家的面积看起来比实际的面积要大,并且维度越高面积偏离的幅度越大。

在地图投影之前,我们先来明确一下经纬度的基本概念。经度的范围是-180度到180度,负数代表西经,正数代表东经。纬度的范围是-90度到90度,负数代表南纬,正数代表北纬。

接下来,我们就可以将经纬度按照墨卡托投影的方式转换为画布上的x、y坐标。对应的经纬度投影如下图所示。

注意,精度范围是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仓库中,你可以下载到本地运行。这里,我直接把运行的结果展示给你看。

以上,就是利用GeoJSON数据绘制地图的全过程。这个过程非常简单,我们只需要将coordinate数据进行投影,然后根据投影的坐标把轮廓绘制出来就可以了。但是,GeoJSON数据通常比较大,如果我们直接在Web应用中使用,有些浪费带宽,也可能会导致网络加载延迟,所以,使用TopoJSON数据是一个更好的选择。

举个例子,同样的世界地图数据,GeoJSON格式数据有251KB,而经过了压缩的TopoJSON数据只有84KB,体积约为原来的1/3。

尽管体积比GeoJSON数据小了不少,但是TopoJSON数据经过了复杂的压缩之后,我们在使用的时候还需要对它解压,把它变成GeoJSON数据。可是,如果我们自己写代码去解压,实现起来比较复杂。好在,我们可以采用现成的工具对它进行解压。这里,我们可以使用GitHub上的TopoJSON官方仓库的JavaScript模块来处理TopoJSON数据。

这个转换简单到只用一行代码就可以完成,转换完成之后,我们就可以用同样的方法将世界地图绘制出来了。具体的转换过程我就不多说了,你可以自己试一试。转换代码如下:

const countries = topojson.feature(worldData, worldData.objects.countries);

步骤三:整合数据

有了世界地图之后,下一步就是将疫情的JSON数据整合进地图数据里面。

在GeoJSON或者TopoJSON解压后的countries数据中,除了用geometries字段保存地图的地区信息外,还用properties字段来保存了其他的属性。在我们这一份地图数据中,properties只有一个name属性,对应着不同国家的名字。

我们打开TopoJSON文件就可以看到在contries.geometries下的properties属性中有一个name属性,对应国家的名字。

这个时候,我们再打开疫情的JSON数据,我们会发现疫情数据中的contry属性和GeoJSON数据里面的国家名称是一一对应的。

这样,我们就可以建立一个数据映射关系,将疫情数据中的每个国家的疫情数据直接写入到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个等级,每个等级用不同的颜色表示:

对应的代码如下:

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日的疫情地图。

可是,疫情的数据每天都会更新,如果想让疫情地图随着日期自动更新,我们该怎么做呢?我们可以给地图绘制过程加上一个定时器,这样我们就能得到一个动态的疫情地图了,它会自动显示从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);

要点总结

这节课,我们讲了实现地理信息可视化的通用步骤,一共可以分为四步,我们一起来回顾一下。

第一步是准备数据,包括地图数据和要可视化的数据。地图数据有GeoJSON和TopoJSON两个规范。相比较而言,TopoJSON数据格式经过了压缩,体积会更小,比较适合Web应用。

第二步是绘制地图。要绘制地图,我们需要将地理信息中的坐标信息投影到地图上,最简单的投影方式是使用墨卡托投影。

第三步是整合数据,我们要把可视化数据和地图的地理数据集成到一起,这一步我们可以通过定义数据映射函数来实现。

最后一步,就是将数据与地图结合,根据整合后的数据结合地图完成最终的图形绘制。

总的来说,无论我们要实现多么复杂的地理信息可视化地图,核心的4个步骤是不会变的,只不过其中的每一步,我都可以替换具体的实现方式,比如,我们可以使用其他的投影方式来代替墨卡托投影,来绘制不同形状的地图。

课后练习

  1. 我们今天选择使用Canvas来绘制地图,是因为它使用起来十分方便。其实,使用SVG绘制地图也很方便,你能试着改用SVG来实现今天的疫情地图吗?这和使用Canvas有什么共同点和不同点?

  2. 我们今天使用的墨卡托投影是最简单的投影方法,它的缺点是让高纬度下的国家看起来比实际的要大很多。你能试着使用D3.js的d3-geo模块中提供的其他投影方式来实现地图吗?

3.如果 我们要增加交互,让鼠标移动到某个国家区域的时候,这个区域高亮,并且显示国家名称、疫情确诊数、治愈数以及死亡数,这该怎么处理呢?你可以尝试增加这样的交互功能,来完善我们的地图应用吗?

好啦,今天的地理信息可视化实战就到这里了。欢迎你把实现的地图作品放在留言区,也欢迎把这节课转发出去,我们下节课见!


源码

[1] 新冠肺炎数据
[2] GeoJSON数据
[3] TopoJSON数据
[4] 完整的示例代码见GitHub仓库

推荐阅读

[1] GeoJSON标准格式学习
[2] [GeoJSON和TopoJSON]reference_end
[3] GeoJSON规范
[4] TopoJSON规范

评论