前言

笔者曾在三年前写过一篇如何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请求对应的数据块即可。

pmtiles格式的设计理念,来自Protomaps博客
pmtiles格式的设计理念,来自Protomaps博客

获取一个图块的过程如下(假设我们要获取z:8 x:65 y:95):

  1. 获取前 512 千字节并将目录解析为索引
  2. 在这种情况下,通过您想要的图块的键查找8_65_95
  3. 匹配成功!您将获得一个Rangeoffset: 785366 length: 21400
  4. 使用HTTP Range获取这些字节Range:bytes=785366-806765并将数据解析为图像

但值得注意的是,当整个文件足够大的时候,图块索引本身也可能占用大量的体积(如博客文章所说,如果我们要存储1-15这15个缩放登记的索引,索引本身的体积可能就高达6G。这对在线地图服务依然是难以接受的。pmtiles对此的解决方案是再添加一个中间层(类似索引的索引),第一个索引只负责缩放级别0-7,如果我们要查找的图块缩放级别大于7,则找到位置对应的缩放级别7的图块后,再检查文件是否有提供叶目录(leaf directory)存储更详细的缩放级别,如果有,重复上述查找过程即可。

Leaf Directory,来自Protomaps博客
Leaf Directory,来自Protomaps博客

如何使用叶目录查找z:14 x:4204 y:6090

  1. 获取前512千字节,解析根索引。
  2. 检查根索引中的14_4204_6090。由于根目录只包含 0-7 级,相关的位置不存在于根索引里。
  3. 检查根索引中是否存在父图块的叶目录条目z:14 x:4204 y:6090的父图块是z:7 x:32 y:47
  4. 获取叶索引的字节并将其解析到索引中。
  5. 使用字节偏移量和图块数据长度来索引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>
如果需要在本地host style里的sprite,字体等文件,可以在 https://github.com/protomaps/basemaps-assets/ 里下载。

经过笔者测试,在修改完数据源和style json之后,Protomaps的配置可以和maplibre-gl-js无缝兼容,无需进去其他的代码修改。

显示效果

在地图上叠加了geojson和数个marker(车辆)
在地图上叠加了geojson和数个marker(车辆)

底图多语言显示(可以在style json里配置)
底图多语言显示(可以在style json里配置)

参考文档