前言
笔者曾在三年前写过一篇如何self host基于OpenStreetmap数据的博客。在当时的文章里,笔者使用的后端是maptiler开发的的tileserver-gl。它需要一个转换好的mbtiles文件,才能正常serve地图。但遗憾的是,maptiler把下载转换好的地图作为一项商业服务提供,我们只能自己使用maptiler提供的openmaptiles对地图进行转换。由于mbtiles的本质是基于sqlite的地图数据库,使用openmaptiles制作地图的速度相当缓慢(在一台48C256G的机器上转换全世界的地图大概需要一整天的时间,并且对磁盘的IO有较高的要求),host地图也需要使用tileserver-gl处理请求,将mbtiles里的数据转换成pbf格式发送给客户端。
直到最近,笔者注意到了一些GIS开源项目在使用的Protomaps项目,以及他们提出的pmtiles格式。如Protomaps的发起人给博客起的标题《Dynamic Maps, Static Storage》那样,pmtiles的一大优势是如果不需要raster tiles,服务端不再需要安装任何软件,只需要一个“Static Storage”即可(无需任何后端软件对地图数据进行处理)。确切的说,一个支持HTTP Range的服务器。包括nginx,caddy等常见的web服务器和S3服务理论上都是支持这项特性的。
pmtiles格式的设计理念
Pmtiles的格式设计巧妙的利用了HTTP Range的特性。如下图所示,它将图块数据的索引放在了文件的头部,因此客户端只要先对文件的头部进行查询,得到图块索引后,根据Z、X、Y找到对应的数据range,最后再使用HTTP Range请求对应的数据块即可。
获取一个图块的过程如下(假设我们要获取
z:8 x:65 y:95
):
- 获取前 512 千字节并将目录解析为索引
- 在这种情况下,通过您想要的图块的键查找
8_65_95
- 匹配成功!您将获得一个Range
offset: 785366 length: 21400
- 使用HTTP Range获取这些字节
Range:bytes=785366-806765
并将数据解析为图像
但值得注意的是,当整个文件足够大的时候,图块索引本身也可能占用大量的体积(如博客文章所说,如果我们要存储1-15这15个缩放登记的索引,索引本身的体积可能就高达6G。这对在线地图服务依然是难以接受的。pmtiles对此的解决方案是再添加一个中间层(类似索引的索引),第一个索引只负责缩放级别0-7,如果我们要查找的图块缩放级别大于7,则找到位置对应的缩放级别7的图块后,再检查文件是否有提供叶目录(leaf directory)存储更详细的缩放级别,如果有,重复上述查找过程即可。
如何使用叶目录查找
z:14 x:4204 y:6090
:
- 获取前512千字节,解析根索引。
- 检查根索引中的
14_4204_6090
。由于根目录只包含 0-7 级,相关的位置不存在于根索引里。- 检查根索引中是否存在父图块的叶目录条目。
z:14 x:4204 y:6090
的父图块是z:7 x:32 y:47
。- 获取叶索引的字节并将其解析到索引中。
- 使用字节偏移量和图块数据长度来索引
14_4204_6090
。只要客户端缓存最近使用的索引表,并且缩放/平移保持在
z:7 x:32 y:47
这个大图块内,则客户端无需多次提取索引。
迁移mbtiles到pmtiles
数据源
相比mbtiles下载需要收费(免费层级只能下载2020年的数据),Protomaps在 https://maps.protomaps.com/builds/ 提供了每日更新的地图数据,直接点击下载即可。如果不需要整个星球的数据,也可以在用pmtiles CLI,根据geojson来提取部分数据下载。
后端
将下载下来的pmtiles放在任何支持HTTP Range的web server或者对象存储上即可,除了CORS之外无需做任何特别配置。对于比较小的地图,放在github.io等静态网页存储服务上都是可行的。
前端
js
protomaps也提供了针对maplibre-gl-js的集成,只需要再引入一个pmtiles.js,并在js里注册这个protocal即可:
let protocol = new pmtiles.Protocol({metadata: true});
maplibregl.addProtocol("pmtiles", protocol.tile);
style json
可以在 https://maps.protomaps.com/ 上点击Get Style Json生成,生成后记得修改source里的url(改为类似pmtiles://https://url.com/example.pmtiles
的格式):
"sources": {
"protomaps": {
"type": "vector",
"attribution": "<a href=\"https://github.com/protomaps/basemaps\">Protomaps</a> © <a href=\"https://openstreetmap.org\">OpenStreetMap</a>",
"url": "pmtiles://https://url.com/example.pmtiles"
}
},
示例网页:
<html>
<head>
<title>PMTiles MapLibre Example</title>
<meta charset="utf-8"/>
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/maplibre-gl.css" crossorigin="anonymous">
<script src="https://unpkg.com/[email protected]/dist/maplibre-gl.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/[email protected]/dist/pmtiles.js"></script>
<style>
body {
margin: 0;
}
#map {
height:100%; width:100%;
}
</style>
</head>
<body>
<div id="map"></div>
<script type="text/javascript">
// add the PMTiles plugin to the maplibregl global.
// setting metadata = true fills out the "attribution" field of the source, and is required for some inspector applications,
// but requires an additional blocking HTTP request before loading the map.
let protocol = new pmtiles.Protocol({metadata: true});
maplibregl.addProtocol("pmtiles", protocol.tile);
const map = new maplibregl.Map({
container: "map",
zoom: 13,
center: [11.2543435, 43.7672134],
style: "pmtiles-light.json",
});
map.showTileBoundaries = true;
</script>
</body>
</html>
经过笔者测试,在修改完数据源和style json之后,Protomaps的配置可以和maplibre-gl-js无缝兼容,无需进去其他的代码修改。