前端离线地图获取及渲染的实现(Vue-Leaflet + Mapbox)

Posted by Ivens on March 12, 2021

目标:

为了应对会场展馆内可能出现的无网络情况,在不更换使用技术栈的情况下,实现地图的离线加载。

现状:

接手当前的前端项目,使用的前端框架为 Vue,地图展示使用的组件为 Vue-Leaflet,以下是相关代码:

1
2
3
4
<l-tile-layer
    :url="tileProvider.url"
    layer-type="base"
/></>
1
2
3
4
5
6
tileProviders: [
    {
      name: "test",
      url: 'https://api.mapbox.com/styles/v1/zhangy123/ckkessgis1ktq17oad1yky6sf/tiles/512/{z}/{x}/{y}?access_token=pk.eyJ1Ijoiemhhbmd5MTIzIiwiYSI6ImNra2RyMGprczA1amsybnF0NmZmMjZ0MXcifQ.cFSvfTNV6AQKP4g2FmTL7w'
    },
],

这里使用了在线加载地图,使用的是通过 Mapbox studio 自定义后的地图,这里我们的 url 设置为对应的接口地址,每次向 https://api.mapbox.com 发送请求获取对应地图。

尝试一(Mapbox、高德地图、百度地图)

首先,研究当前使用的 mapbox 可否支持离线加载功能。

Mapbox 的官方文档中写:使用AndroidiOS的Mapbox Maps SDK构建的可以下载选定区域的地图,以在设备缺乏网络连接时使用。在 web 端无法做到离线地图加载。

参考:《Mapbox Offline maps》


高德地图、百度地图离线地图支持

再看看国内的百度地图与高德地图是否支持离线地图加载。

  高德地图 百度地图
web 离线 不支持 不支持
Android 离线 支持 支持
iOS 离线 支持 支持

参考:

结论:目前调查的几个地图服务提供商所支持的离线地图加载都是建立在 Android SDKiOS SDK 的基础上,对于 web 应用的离线支持目前没有,原因我认为是离线地图涉及庞大的静态文件,如果直接放在前端项目中会造成加载缓慢。

尝试二(Bigemap)

偶然发现国内有家地图厂商可以提供离线地图加载功能,可以在 Bigemap 官网看到相对的使用教程:

该软件确实可以完美实现地图的获取+运行本地地图服务,且可以自定义地图样式,但有一个问题是该软件不是免费软件,在实现基本的离线地图展示下,两款软件(地图下载器、离线地图服务器)的总价大约在 7000+ 人民币,在预算不充足的情况下这种解决方案不予考虑。

尝试三(瓦片地图)

首先科普一下什么是瓦片地图?

地球是一个椭球,使用 Web墨卡托投影(又称球体墨卡托投影,是墨卡托投影的变种)地球就变为平面的一张地图。

对这张地图进行等级切分。在最高级(zoom=0),需要的信息最少,只需保留最重要的宏观信息,因此用一张256x256像素的图片表示即可;在下一级(zoom=1),信息量变多,用一张512x512像素的图片表示;以此类推,级别越低的像素越高,下一级的像素是当前级的4倍。这样从最高层级往下到最低层级就形成了一个金字塔坐标体系。

我们使用的 Mapbox 在瓦片编号规则上与 Google Map 一致,Z 表示缩放层级,Z = zoom;XY 的原点在左上角,X 从左向右,Y 从上向下,可以参考下图:

其实 Mapbox 实现的在线加载是同样的道理,Vue-Leaflet 负责将需要的层级、横纵坐标提取出来,并向我们给定的地址发送请求。在 Chrome 的调试工具中可以看出即使是在线地图,资源也是按照瓦片一块一块加载的:

最重要的一点是 Mapbox 返回地图没有做防爬虫处理直接是 png 格式,我们可以将所需层级所有的瓦片地图爬取下来。

实现思路

Vue-Leaflet 的控制下,我们将地图以 /z/x/y.png 的格式保存下来即可,然后在本地启动一个 node 服务,让 Vue-Leafleturl 绑定到我们启动的本地服务地址即可。

实现步骤

① 创建地图

登录 Mapbox Studio,这里需要注册一个账号,然后创建新地图:

Mapbox Studio 的功能十分强大,可定制性十分高,包括不限于国际化、图例定制、自定义显示等。

定制好地图后可以点击右上角 Publish 发布,这时会拿到一个链接,这也就是我们可以绑定到 Vue-Leafleturl 中实现在线地图展示的链接。

可以仔细看下这个链接,分为地址 + token:

1
https://api.mapbox.com/styles/v1/zhangy123/ckkessgis1ktq17oad1yky6sf.html?fresh=true&title=view&access_token=pk.eyJ1Ijoiemhhbmd5MTIzIiwiYSI6ImNra2RyMGprczA1amsybnF0NmZmMjZ0MXcifQ.cFSvfTNV6AQKP4g2FmTL7w

② 爬取地图

这里我们用 node.js 写了一个简单爬虫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// getMap.js
const fs = require('fs'); //引入文件读取模块
const request = require('request');

// ================== Config =========================================
// 图片保存到的地址
const dir = `assets/map`
// 爬取层级, 每级的横纵块数
const level = 6
// 地图配置相关
let imgUrlRoot = `https://api.mapbox.com/styles/v1/meroychen/ckiod6ao60b0p17sg086wtnb4/tiles/512`
const token = `pk.eyJ1IjoibWVyb3ljaGVuIiwiYSI6ImNraTc2NTR2ejRlNGQyeHJtc29jcTJ4ZXUifQ.xrCXPnyPfIuxHpXXdmcPMQ`
// ================== Config =========================================

// 错误处理
const catchErr = err => {
  if (err) throw err;
}

// 三层循环(x、y、z轴)爬取地图
for (let levelTemp = 1; levelTemp <= level; levelTemp ++) {
  // 创建对应深度文件夹
  fs.mkdirSync(`${dir}/${levelTemp}`, catchErr)
  // 在瓦片地图中每个深度的横轴、纵轴数量相等,数量为 2^(深度数)
  const horizontalNum = Math.pow(2, levelTemp) -1

  for (let horizontal = 0; horizontal <= horizontalNum; horizontal ++ ) {
    // 创建对应横轴文件夹
    fs.mkdirSync(`${dir}/${levelTemp}/${horizontal}`, catchErr)
    const verticalNum = Math.pow(2, levelTemp) -1

    for ( let vertical = 0; vertical <= verticalNum; vertical ++) {
      // 创建对应纵轴文件夹
      const imgUrl = `${imgUrlRoot}/${levelTemp}/${horizontal}/${vertical}?access_token=${token}`

      // 利用 request 获取图片,在管道中创建文件写入流
      request(imgUrl).pipe(fs.createWriteStream(`${dir}/${levelTemp}/${horizontal}/${vertical}.jpg`));
    }
  }
}

注意:这里的 imgUrlRoottoken 使用上文中 Mapbox 提供的链接。

如果一切正常我们可以看到一个如下的目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.
├── 1
│   ├── 0
│   └── 1
├── 2
│   ├── 0
│   ├── 1
│   ├── 2
│   └── 3
├── 3
│   ├── 0
│   ├── 1
│   ├── 2
│   ├── 3
│   ├── 4
│   ├── 5
│   ├── 6
│   └── 7
……

进入目录,里面保存的图片都应该是直接可以点开查看的,如果无法打开则说明保存失败。

③ 本地图片服务

地图爬取完成后,我们需要再写一个可以按照前端发送的链接返回对应图片的服务。

前端发送的请求格式为:http://localhost:<port>/z/x/y.png,我们只需将后缀的 z/x/y.png 截取下来,然后利用 node.js 启动一个 http server,然后利用 fs 模块,对对用的文件进行读取,然后返回到前端。

代码参考如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// map-server.js
const http = require('http');
const fs = require('fs'); //引入文件读取模块

// 设置侦听端口号
const port = 8081

// 需要访问的文件的存放目录
// 这里使用相对路径,匹配当前地图文件存放在前端项目中的情况。
let documentRoot = 'assets/map';

let server = http.createServer((req, res) => {

		// 这里对 url 做了去除首字母的处理
		let url = req.url
		let file = documentRoot + url;	// flie 格式为:assets/map/5/21/12.jpg

		fs.readFile(file, (err, data) => {
				if (err) {
					res.writeHeader(404, {
						'content-type': 'text/html;charset="utf-8"'
					});
					res.write('<h1>404错误</h1><p>你要找的页面不存在</p>');
					res.end();
				} else {
					res.writeHeader(200, {
					});
					res.write(data); //将index.html显示在客户端
					res.end();
				}
			});
	}).listen(port);

console.log(`地图服务器开启成功,端口:${port}`);

④ 修改 Vue-Leaflet 配置项

如果是按照上文中 map-server.js 的代码,那我们当前的链接应该为 http://localhost:8081/{z}/{x}/{y}.jpg

我们之前提到,Vue-Leaflet 展示地图依靠我们给他绑定的 url,现在可以回到前端代码中,将此前绑定的 Mapbox 的在线链接替换为上文启动的本地服务的链接即可。

Reference: