从零开始做点阵地图

avatar
Mofei Zhu

最近有不少人看到我放在个人主页上的地图之后,问我是怎么做的或者是用了什么插件。

e7c39be65f0158f921530926ca16b612.gif

这张地图最早诞生在几年前的一次博客改版,当时是想把我去过的地方都标记出来,梦想着什么时候能把整个世界地图点亮。

最近抽空把这个整个地图的开发过程整理了出来,也顺便把这个项目放在了Github上,有兴趣的小伙伴可以通过 https://github.com/zmofei/point-map 查看 (别忘了打赏个Star~)

从零开始做点阵地图

写这篇文章的时候,我决定从零开始说起,也就是说你只需要掌握JavaScript,甚至不需要有数据,也可以了解个完整的流程。动手能力强的话甚至能手写一个这样的库(对于这一条,我认为可能是个玄学),当然了,如果你想体验全部的数据处理过程,你可能需要安装一个GIS行业的利器QGIS,不过这也不是必须,我在项目的 /data 目录下也保留了处理好的数据,你可以直接去使用。

总体思路

实现这样一个地图,最关键的第一步是我们如何拿到代表陆地的点阵数据。这样的数据一般很少能直接下载到,所以我们需要通过自己的方法将它处理出来。这里我用的方式是通过Canvas的一些小技巧,从一个世界陆地的边界数据中将这些点阵数据抽取出来了,然后根据这些点阵数据定制一个我们自己的坐标系,最后把点阵和代表事件的点通过Canvas绘制出来,就完成了这样的一个作品。

1. 点阵数据

1.1 获取数据

想要拿到直接可用的点阵数据是有些困难的,好在作为程序员的我们可以通过自己的双手,再结合一些常见的世界陆地边界数据去创造出我们可用的点阵数据。

边界数据我们可以通过 Natural Earth 的网站去下载,这个网站上有很多常见比例尺的地理边界或者『文化』边界的数据。由于点阵地图对于精度要求不高,所以我们只需下载1:110m的Physical数据就好。下载下来的数据是Shapfile格式的文件,我们可以通过QGIS软件打开它。

3DAA17AB-DBA0-46B7-AC1B-63D225CC8DC6.png

由于下一步我们需要通过Canvas去识别陆地,所以我们先通过QGIS把它导出成图片格式,需要留意的是,这里我们一定要记录下我们导出的图片范围,以便为后续的坐标系做准备。如下图所示,我们导出的范围是[W: -180°, N: 85°, E: 180°, S: -85°]

B3291ABA-D8B8-4DB6-8DB8-F7A4EAED4847.png

接下来,我们可以用PS(Photoshop)简单的把图片中的白色的海洋部分裁剪掉,当然,由于白色的背景和陆地的颜色本身就有很大的区别,你可以通过代码很容易的区分出来,所以这一步仅仅是为了视觉上的好辩识其实并不是必须的。

1.2 处理数据

接下来就是代码大显神通的时候了,相关的数据处理的代码我也放在了Github的/data目录中了,可供参考。

为了识别图片上的大陆我们先用Canvas把图片绘制到画布上, 对应的代码,这里主要用到了ctx.drawImage方法,画出来之后的样子应该是这个样子。

C0F8C9D5-3BC6-466E-9E4A-C354AB409F48.png

画出来之后,我们就可以通过Canvas的getImageData方法获取每一个像素点的RGBA值(具体的代码在这里),之后再去遍历每一个像素点,如果某个点的R,G,B三个值都不是0(如果你之前的流程中没有裁剪白色背景的话,需要判断这3个值都不是255)那么就表示该处是陆地。

另外,由于我们最终要获取到的是点阵图,每一个点其实是有自己的宽度的,并且和别的点也是有一定的距离的,所以我们不需要获取每一个像素点的值,只需定一个点阵之间的间隙,比如代码中的girdWidth值,然后按照这个值在买一个网格中取出一个点即可,最后我们就可以拿到这样一份陆地的点阵数据了。如果把这些点阵绘制出来的话,应该是下面这个样子。

FA770A62-8111-447F-B2E9-57822EE0919B.png

PS:这个图片似乎让我想到了小时候玩的红白游戏机的画面。。。

另外,在/data/extradata.js 我已经把点阵的数据输出在控制台中了,如果想要去查看的话,可以直接打开/data/extradata.html文件,去看控制台的输出。

1.3 数据的压缩

通过上述方法,我们可以得到一系列的点阵数据,在思考如何保存这些数据的过程中我做了几次简单的优化,将数据压缩到了最初的12%。

1.3.1 1st Generation 简单粗暴的[x,y]数组表示法

由于点阵数据是由X,Y坐标组成的,所以最简单的想法就是直接将这些点阵数据存储到一个数组中。这样我们就得到了第一代数据的结果:

[
    [0,7],[0,8],
    [1,7],
    [2,7],[2,8],
    [3,8],
    [6,8],[6,9],[6,10],
    [7,6],[7,7],[7,8],[7,9],[7,10],[7,12],[7,65],
    [8,6],[8,7],[8,8],[8,9],[8,10],
    //...
]

按照字符数(忽略空格换行)计算(使用了JSON.stringify().length方法)这样的一组数据的字符长度是27447。

1.3.2 2nd Generation 利用数组的索引保存X值

观察了数据一段时间之后,我们发现几乎每一列都会有点阵分布(极少数的情况,某一列完全没有数据),而且每一列的数据都会有重复的X值,那么我们能不能利用数组的索引去存储X值呢?经过一系列的修改,我们得到了如下的第二代数据结果:

[
    [7,8],
    [7],
    [7,8],
    [8],
    [],
    [],
    [8,9,10],
    [6,7,8,9,10,12,65],
    [6,7,8,9,10],
    //...
]

虽然多出了一些空的数组[],但是由于我们去除了重复量大的X值,我们的结果的字符长度从27447降低到了9754,足足降低了 64% 的数据量!

1.3.3 3rd Generation 合并递增Y值

再次观察数据,我们发现由于大陆的大部分地区都是连续的,数据中很容易出现类似[6,7,8,9,10,12,65]的部分连续的值,那么我们能不能想个办法去合并连续递增量呢?我们决定用二维数组表示连续递增数据,如[6,7,8,9,10,12,65]中的前5位是从6开始的连续递增值,所以我们可以用[6,5]来表示这5个值,其中6是起始值,5表示连续5位。所以[6,7,8,9,10,12,65]=>[[6,5],12,65],通过这种方法我们得到了第三代的数据:

[
    [[7,2]],
    [7],
    [[7,2]],
    [8],
    [],
    [],
    [[8,3]],
    [[6,5],12,65],
    [[6,5]],
    //...
]

第三次迭代我们把结果的字符串长度再次从9754降低到了3345,再次降低了 66% 的数据量!

通过3次迭代我们把数据从27447降低到了3345,数据减少了惊人的 88% !那么能不能继续减少呢?答案是肯定的,但是由于时间的原因我们没有继续优化下去。

两次关键优化代码具体可以参考文件/data/extradata.js#L27 #L46-L57。 对于压缩后的数据解压缩的算法在 /src/helper.jsdataDecode方法中

2. 实现

拿到所有的数据之后,接下来就到了最核心的绘制步骤了,如果你熟悉Canvas的绘制的话整个过程实际上并不是非常的复杂,这里我们简单的结合代码聊一下绘制的重点。

2.1 坐标体系

虽然我们当前的版本并不需要地图的复杂交互,如缩放、拖拽等,但是为了能让各个点阵中的点能正常显示,以及后续可以通过经纬度添加事件点等,建立一个简易的坐标系还是十分有必要的。

结合我们第一步抽取点阵的参数,我们用一个BBOX(边界点,通常记录一个矩形的左下和右上两个点)以及grid字段来表示我们的地图区域。

this.coordinate = { bbox: [-180, -85, 180, 85], grid: 2.5 }

Code link

根据这个bbox,我们可以计算出整个地图的跨越的东西经度跨越的范围是 -180° 到 180° 也就是 360°,南北纬度跨越的范围是 -85° 到 85° 也就是170°,然后grid参数是用来标记每个网格的大小,这里我们以每2.5°作为一个网格,大致的示意图如下:

8EEBB88F-095D-42F1-8958-DBFE18BF51A8.png

有了上面的概念之后,我们就可以计算出我们需要绘制的网格数量了:

  • 东西横跨经纬度(360)/每个网格的大小(2.5)可以得到每一行有多少个网格了(360 / 2.5 = 144)
  • 同理也可以计算出一共有多少行网格(170 / 2.5 = 68)

知道网格的行数和列数,再获取画布的实际像素长度和宽度,接下来就可以很容易计算出每个网格的像素大小了(网格宽度=画布宽度/网格个数)。

2.2 绘制

直接从效果图上我们可以看到点阵地图分为如下的几个绘制层:

  • 背景点阵
  • 鼠标高亮的点(鼠标在地图上移动时,被划过的点会高亮)
  • 事件的中心高亮点
  • 事件动画的波纹效果

大多数情况下,我们可以按照每一个图层的先后顺序依次绘制,然后通过requestAnimationFrame不停的绘制,已实现动画。但是考虑到我们的背景在绘制到地图上之后几乎不会有变动,所以我们可以采取动静结合的方式,把动画效果和非动画效果分开来绘制,这样可以减少非动画效果不停的绘制带来的消耗。

所以我们把整个地图分成2个离屏Canvas以及一个最终用来展示的Canvas来进行。

离屏Canvas1:主要用来绘制地图底图,鼠标悬停点,以及事件中心点: ScreenFlow-19083002.gif

对应代码 drawBasicMap() drawEventPoint

另外一个离屏Canvas2我们用来绘制气泡: ScreenFlow-19083001.gif

气泡的绘制方式这里就不多说了,如果有需要可以文章后留言,有必要的话我会再写一篇文章。对应代码地址: drawEventPointWave()

最后我们只要把这2步的离屏Canvas放在一起就大功告成了!

e7c39be65f0158f921530926ca16b612.gif

收尾

最后将所有的工程简单的收尾一下,添加一些公用的方法如:

  • on() 绑定事件
  • remove() 移出事件
  • addEvent() 添加事件
  • addEvents() 批量添加事件

再妥善的处理编译,发布流程然后就大功告成啦!

PS: 对应的代码已经发布在了 https://github.com/zmofei/point-map 中,感兴趣的小伙伴可以自行阅读源码或者通过NPM、CND直接引入等方式直接使用!