说到地图,大家一定很熟悉,平时应该都使用过百度地图、地图、腾讯地图等,如果涉及到地图相关的开发需求,也有很多选择,比如前面的几个地图都会提供一套,此外也有一些开源地图框架可以使用,比如、等。
那么大家有没有想过这些地图是怎么渲染出来的呢,为什么根据一个经纬度就能显示对应的地图呢,不知道没关系,本文会带各位从零实现一个简单的地图引擎,来帮助大家了解基础知识及地图的实现原理。
首先我们去地图上选个经纬度,作为我们后期的地图中心点,打开地图工具,随便选择一个点:
笔者选择了杭州的雷峰塔,经纬度为:。
地图瓦片我们使用的在线瓦片,地址如下:
目前各大地图厂商的瓦片服务遵循的规则是有不同的:
谷歌XYZ规范:谷歌地图、OpenStreetMap、地图、geoq、天地图,坐标原点在左上角 TMS规范:腾讯地图,坐标原点在左下角 WMTS规范:原点在左上角,瓦片不是正方形,而是矩形,这个应该是官方标准 百度地图比较特立独行,投影、分辨率、坐标系都跟其他厂商不一样,原点在经纬度都为0的位置,也就是中间,向右为X正方向,向上为Y正方向
谷歌和的瓦片区别可以通过该地址可视化的查看:
虽然规范不同,但原理基本是一致的,都是把地球投影成一个巨大的正方形世界平面图,然后按照四叉树进行分层切割,比如第一层,只有一张瓦片,显示整个世界的信息,所以基本只能看到洲和海的名称和边界线,第二层,切割成四张瓦片,显示信息稍微多了一点,以此类推,就像一个金字塔一样,底层分辨率最高,显示的细节最多,瓦片数也最多,顶层分辨率最低,显示的信息很少,瓦片数量相对也最少:
每一层的瓦片数量计算公式:
十八层就需要张瓦片,所以一套地图瓦片整体数量是非常庞大的。
瓦片切好以后,通过行列号和缩放层级来保存,所以可以看到瓦片地址中有三个变量:、、
通过这三个变量就可以定位到一张瓦片,比如下面这个地址,行号为,列号为,缩放层级为:
对应的瓦片为:
关于瓦片的更多信息可以阅读瓦片地图原理。
地图使用的是,也称火星坐标系,由中国国家测绘局在02年发布,是在GPS坐标(坐标系)基础上经加密后而来,也就是增加了非线性的偏移,让你摸不准真实位置,为了国家安全,国内地图服务商都需要使用。
坐标系是国际通用的标准,编号为,通常GPS设备获取到的原始经纬度和国外的地图厂商使用的都是坐标系。
这两种坐标系都是地理坐标系,球面坐标,单位为,这种坐标方便在地球上定位,但是不方便展示和进行面积距离计算,我们印象中的地图都是平面的,所以就有了另外一种平面坐标系,平面坐标系是通过投影的方式从地理坐标系中转换过来,所以也称为投影坐标系,通常单位为,投影坐标系根据投影方式的不同存在多种,在开发的场景里通常使用的是,编号为,它基于,把坐标系投影成正方形:
这是通过舍弃了南北以上的地区实现的,因为它是正方形,所以一个大的正方形可以很方便的被分割为更小的正方形。
坐标系更详细的信息可参考GIS之坐标系统,的详细信息可参考EPSG:3857。
上一节里我们简单介绍了一下坐标系,按照地图的标准,我们的地图引擎也选择支持投影,但是我们通过工具获取到的是火星坐标系的经纬度坐标,所以第一步要把经纬度坐标转换为投影坐标,这里为了简单,先直接把火星坐标当做坐标,后面再来看这个问题。
转换方法网上一搜就有:
坐标有了,它的单位是,那么怎么转换成瓦片的行列号呢,这就涉及到的概念了,即地图上一像素代表实际多少米,分辨率如果能从地图厂商的文档里获取是最好的,如果找不到,也可以简单计算一下(如果使用计算出来的也不行,那就只能求助搜索引擎了),我们知道地球半径是米,坐标系把地球当做正圆球体来处理,所以可以算出地球周长,投影是贴着地球赤道的:
所以投影成正方形的世界平面图后的边长代表的就是地球的周长,前面我们也知道了每一层级的瓦片数量的计算方式,而一张瓦片的大小一般是像素,所以用地球周长除以展开后的世界平面图的边长就知道了地图上每像素代表实际多少米:
地球周长算出来是,可以看到就是这么计算的:
坐标的单位是,那么把坐标除以分辨率就可以得到对应的像素坐标,再除以,就可以得到瓦片的行列号:
函数如下:
接下来我们把层级固定为,那么分辨率就是,雷峰塔的经纬度转成的坐标为:,使用上面的函数计算出来行列号为:,我们把这几个数据代入瓦片的地址里进行访问:
一片空白,这是为啥呢,其实是因为原点不一样,和坐标系的原点在赤道和本初子午线相交点,非洲边上的海里,而瓦片的原点在左上角:
再来看下图会更容易理解:
坐标系的原点相当于在世界平面图的中间,向右为轴正方向,向上为轴正方向,而瓦片地图的原点在左上角,所以我们需要根据图上【绿色虚线】的距离计算出【橙色实线】的距离,这也很简单,水平坐标就是水平绿色虚线的长度加上世界平面图的一半,垂直坐标就是世界平面图的一半减去垂直绿色虚线的长度,世界平面图的一半也就是地球周长的一半,修改函数:
这次计算出来的瓦片行列号为,代入瓦片地址:
结果如下:
可以看到雷峰塔出来了。
我们现在能根据一个经纬度找到对应的瓦片,但是这还不够,我们的目标是要能在浏览器上显示出来,这就需要解决两个问题,一个是加载多少块瓦片,二是计算每一块瓦片的显示位置。
渲染瓦片我们使用画布,模板如下:
地图画布容器的大小我们很容易获取:
地图中心点我们设在画布中间,另外中心点的经纬度和缩放层级因为都是我们自己设定的,所以也是已知的,那么我们可以计算出中心坐标对应的瓦片:
缩放层级还是设为,中心点还是使用雷峰塔的经纬度,那么对应的瓦片行列号前面我们已经计算过了,为。
中心坐标对应的瓦片行列号知道了,那么该瓦片左上角在世界平面图中的像素位置我们也就知道了:
计算出来为。这个坐标怎么转换到屏幕上呢,请看下图:
中心经纬度的瓦片我们计算出来了,瓦片左上角的像素坐标也知道了,然后我们再计算出中心经纬度本身对应的像素坐标,那么和瓦片左上角的差值就可以计算出来,最后我们把画布的原点移动到画布中间(画布默认原点为左上角,x轴正方向向右,y轴正方向向下),也就是把中心经纬度作为坐标原点,那么中心瓦片的显示位置就是这个差值。
补充一下将经纬度转换成像素的方法:
计算中心经纬度对应的像素坐标:
计算差值:
最后通过来把中心瓦片渲染出来:
这里先来看看方法的实现:
这里随机了四个子域:、、、,这是因为浏览器对于同一域名同时请求的资源是有数量限制的,而当地图层级变大后需要加载的瓦片数量会比较多,那么均匀分散到各个子域下去请求可以更快的渲染出所有瓦片,减少排队等待时间,基本所有地图厂商的瓦片服务地址都支持多个子域。
为了方便看到中心点的位置,我们再额外渲染两条中心辅助线,效果如下:
可以看到中心点确实是雷峰塔,当然这只是渲染了中心瓦片,我们要的是瓦片铺满整个画布,对于其他瓦片我们都可以根据中心瓦片计算出来,比如中心瓦片左边的一块,它的计算如下:
所以我们只要计算出中心瓦片四个方向各需要几块瓦片,然后用一个双重循环即可计算出画布需要的所有瓦片,计算需要的瓦片数量很简单,请看下图:
画布宽高的一半减去中心瓦片占据的空间即可得到该方向剩余的空间,然后除以瓦片的尺寸就知道需要几块瓦片了:
我们把中心瓦片作为原点,坐标为,来个双重循环扫描一遍即可渲染出所有瓦片:
效果如下:
很完美。
拖动可以这么考虑,前面已经实现了渲染指定经纬度的瓦片,当我们按住进行拖动时,可以知道鼠标滑动的距离,然后把该距离,也就是像素转换成经纬度的数值,最后我们再更新当前中心点的经纬度,并清空画布,调用之前的方法重新渲染,不停重绘造成是在移动的视觉假象。
监听鼠标相关事件:
在方法里计算拖动后的中心经纬度及重新渲染画布:
和属性能获取本次和上一次鼠标事件中的移动值,兼容性不是很好,不过自己计算该值也很简单,详细请移步。乘以当前分辨率把换算成,然后把当前中心点经纬度也转成的坐标,偏移本次移动的距离,最后再转回的经纬度坐标作为更新后的中心点即可。
为什么是减,是加呢,很简单,我们鼠标向右和向下移动时距离是正的,相应的地图会向右或向下移动,坐标系向右和向上为正方向,那么地图向右移动时,中心点显然是相对来说是向左移了,因为向右为正方向,所以中心点经度方向就是减少了,所以是减去移动的距离,而地图向下移动,中心点相对来说是向上移了,因为向上为正方向,所以中心点纬度方向就是增加了,所以加上移动的距离。
更新完中心经纬度,然后清空画布重新绘制:
效果如下:
可以看到已经凌乱了,这是为啥呢,其实是因为图片加载是一个异步的过程,我们鼠标移动过程中,会不断的计算出要加载的瓦片进行加载,但是可能上一批瓦片还没加载完成,鼠标已经移动到新的位置了,又计算出一批新的瓦片进行加载,此时上一批瓦片可能加载完成并渲染出来了,但是这些瓦片有些可能已经被移除画布,不需要显示,有些可能还在画布内,但是使用的还是之前的位置,渲染出来也是不对的,同时新的一批瓦片可能也加载完成并渲染出来,自然导致了最终显示的错乱。
知道原因就简单了,首先我们加个缓存对象,因为在拖动过程中,很多瓦片只是位置变了,不需要重新加载,同一个瓦片加载一次,后续只更新它的位置即可;另外再设置一个对象来记录当前画布上应该显示的瓦片,防止不应该出现的瓦片渲染出来:
因为需要记录瓦片的位置、加载状态等信息,我们创建一个瓦片类:
然后修改之前的双重循环渲染瓦片的逻辑:
效果如下:
可以看到,拖动已经正常了,当然,上述实现还是很粗糙的,需要优化的地方很多,比如:
1.一般会先排个序,优先加载中心瓦片
2.缓存的瓦片越来越多肯定也会影响性能,所以还需要一些清除策略
这些问题有兴趣的可以自行思考。
拖动是实时更新中心点经纬度,那么缩放自然更新缩放层级就行了:
效果如下:
功能是有了,不过效果很一般,因为我们平常使用的地图缩放都是有一个放大或缩小的过渡动画,而这个是直接空白然后重新渲染,不仔细看都不知道是放大还是缩小。
所以我们不妨加个过渡效果,当我们鼠标滚动后,先将画布放大或缩小,动画结束后再根据最终的缩放值来渲染需要的瓦片。
画布默认缩放值为,放大则在此基础上乘以倍,缩小则除以,然后动画到目标值,动画期间设置画布的缩放值及清空画布,重新绘制画布上的已有瓦片,达到放大或缩小的视觉效果,动画结束后再调用重新渲染最终缩放值需要的瓦片。
效果如下:
虽然效果还是一般,不过至少能看出来是在放大还是缩小。
前面还遗留了一个小问题,即我们把工具上选出的经纬度直接当做经纬度,前面也讲过,它们之间是存在偏移的,比如手机获取到的经纬度一般都是坐标,直接在地图显示,会发现和你实际位置不一样,所以就需要进行一个转换,有一些工具可以帮你做些事情,比如、等。
上述效果看着比较一般,其实只要在上面的基础上稍微加一点瓦片的淡出动画,效果就会好很多,目前一般都是使用来渲染地图,如果自己实现动画不太方便,也有一些强大的库可以选择,笔者最后使用库重做了一版,加入了瓦片淡出动画,最终效果如下:
另外只要搞清楚各个地图的瓦片规则,就能稍加修改支持更多的地图瓦片:
具体实现限于篇幅不再展开,有兴趣的可以阅读本文源码。