从零开始做点阵地图
最近有不少人看到我放在个人主页上的地图之后,问我是怎么做的或者是用了什么插件。
这张地图最早诞生在几年前的一次博客改版,当时是想把我去过的地方都标记出来,梦想着什么时候能把整个世界地图点亮。
最近抽空把这个整个地图的开发过程整理了出来,也顺便把这个项目放在了Github上,有兴趣的小伙伴可以通过 https://github.com/zmofei/point-map 查看 (别忘了打赏个Star~)
从零开始做点阵地图
写这篇文章的时候,我决定从零开始说起,也就是说你只需要掌握JavaScript,甚至不需要有数据,也可以了解个完整的流程。动手能力强的话甚至能手写一个这样的库(对于这一条,我认为可能是个玄学),当然了,如果你想体验全部的数据处理过程,你可能需要安装一个GIS行业的利器QGIS,不过这也不是必须,我在项目的 /data
目录下也保留了处理好的数据,你可以直接去使用。
总体思路
实现这样一个地图,最关键的第一步是我们如何拿到代表陆地的点阵数据。这样的数据一般很少能直接下载到,所以我们需要通过自己的方法将它处理出来。这里我用的方式是通过Canvas的一些小技巧,从一个世界陆地的边界数据中将这些点阵数据抽取出来了,然后根据这些点阵数据定制一个我们自己的坐标系,最后把点阵和代表事件的点通过Canvas绘制出来,就完成了这样的一个作品。
1. 点阵数据
1.1 获取数据
想要拿到直接可用的点阵数据是有些困难的,好在作为程序员的我们可以通过自己的双手,再结合一些常见的世界陆地边界数据去创造出我们可用的点阵数据。
边界数据我们可以通过 Natural Earth 的网站去下载,这个网站上有很多常见比例尺的地理边界或者『文化』边界的数据。由于点阵地图对于精度要求不高,所以我们只需下载1:110m
的Physical数据就好。下载下来的数据是Shapfile
格式的文件,我们可以通过QGIS软件打开它。
由于下一步我们需要通过Canvas去识别陆地,所以我们先通过QGIS把它导出成图片格式,需要留意的是,这里我们一定要记录下我们导出的图片范围,以便为后续的坐标系做准备。如下图所示,我们导出的范围是[W: -180°, N: 85°, E: 180°, S: -85°]
接下来,我们可以用PS(Photoshop)简单的把图片中的白色的海洋部分裁剪掉,当然,由于白色的背景和陆地的颜色本身就有很大的区别,你可以通过代码很容易的区分出来,所以这一步仅仅是为了视觉上的好辩识其实并不是必须的。
1.2 处理数据
接下来就是代码大显神通的时候了,相关的数据处理的代码我也放在了Github的/data
目录中了,可供参考。
为了识别图片上的大陆我们先用Canvas把图片绘制到画布上, 对应的代码,这里主要用到了ctx.drawImage
方法,画出来之后的样子应该是这个样子。
画出来之后,我们就可以通过Canvas的getImageData
方法获取每一个像素点的RGBA值(具体的代码在这里),之后再去遍历每一个像素点,如果某个点的R,G,B三个值都不是0(如果你之前的流程中没有裁剪白色背景的话,需要判断这3个值都不是255)那么就表示该处是陆地。
另外,由于我们最终要获取到的是点阵图,每一个点其实是有自己的宽度的,并且和别的点也是有一定的距离的,所以我们不需要获取每一个像素点的值,只需定一个点阵之间的间隙,比如代码中的girdWidth
值,然后按照这个值在买一个网格中取出一个点即可,最后我们就可以拿到这样一份陆地的点阵数据了。如果把这些点阵绘制出来的话,应该是下面这个样子。
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.js
的dataDecode
方法中
2. 实现
拿到所有的数据之后,接下来就到了最核心的绘制步骤了,如果你熟悉Canvas的绘制的话整个过程实际上并不是非常的复杂,这里我们简单的结合代码聊一下绘制的重点。
2.1 坐标体系
虽然我们当前的版本并不需要地图的复杂交互,如缩放、拖拽等,但是为了能让各个点阵中的点能正常显示,以及后续可以通过经纬度添加事件点等,建立一个简易的坐标系还是十分有必要的。
结合我们第一步抽取点阵的参数,我们用一个BBOX(边界点,通常记录一个矩形的左下和右上两个点)以及grid字段来表示我们的地图区域。
this.coordinate = { bbox: [-180, -85, 180, 85], grid: 2.5 }
根据这个bbox,我们可以计算出整个地图的跨越的东西经度跨越的范围是 -180° 到 180° 也就是 360°,南北纬度跨越的范围是 -85° 到 85° 也就是170°,然后grid参数是用来标记每个网格的大小,这里我们以每2.5°作为一个网格,大致的示意图如下:
有了上面的概念之后,我们就可以计算出我们需要绘制的网格数量了:
- 东西横跨经纬度(360)/每个网格的大小(2.5)可以得到每一行有多少个网格了(360 / 2.5 = 144)
- 同理也可以计算出一共有多少行网格(170 / 2.5 = 68)
知道网格的行数和列数,再获取画布的实际像素长度和宽度,接下来就可以很容易计算出每个网格的像素大小了(网格宽度=画布宽度/网格个数)。
2.2 绘制
直接从效果图上我们可以看到点阵地图分为如下的几个绘制层:
- 背景点阵
- 鼠标高亮的点(鼠标在地图上移动时,被划过的点会高亮)
- 事件的中心高亮点
- 事件动画的波纹效果
大多数情况下,我们可以按照每一个图层的先后顺序依次绘制,然后通过requestAnimationFrame
不停的绘制,已实现动画。但是考虑到我们的背景在绘制到地图上之后几乎不会有变动,所以我们可以采取动静结合的方式,把动画效果和非动画效果分开来绘制,这样可以减少非动画效果不停的绘制带来的消耗。
所以我们把整个地图分成2个离屏Canvas以及一个最终用来展示的Canvas来进行。
离屏Canvas1:主要用来绘制地图底图,鼠标悬停点,以及事件中心点:
对应代码 drawBasicMap() drawEventPoint
另外一个离屏Canvas2我们用来绘制气泡:
气泡的绘制方式这里就不多说了,如果有需要可以文章后留言,有必要的话我会再写一篇文章。对应代码地址: drawEventPointWave()
最后我们只要把这2步的离屏Canvas放在一起就大功告成了!
收尾
最后将所有的工程简单的收尾一下,添加一些公用的方法如:
- on() 绑定事件
- remove() 移出事件
- addEvent() 添加事件
- addEvents() 批量添加事件
再妥善的处理编译,发布流程然后就大功告成啦!
PS: 对应的代码已经发布在了 https://github.com/zmofei/point-map 中,感兴趣的小伙伴可以自行阅读源码或者通过NPM、CND直接引入等方式直接使用!