Offline Maps with Protomaps in Maplibre
I have previously talked about Protomaps and PMTiles files (intro to use Protomaps with Leaflet, styling it in Leaflet, creating PMTiles with custom data and tippecanoe, and converting OSM data to PMTiles with Tilemaker). Getting to visualise the data was always done by means of the Leaflet JavaScript library. Leaflet is, however, rather focused on raster tiling. Protomaps are vector tiles. Showing those more cleanly can be done with another JavaScript library: Maplibre, a fork of the now closed source Mapbox.
At first I had dismissed Maplibre because the Protomaps example only showed setting up with NPM. Leaflet was beginning to bother me in some cases, however, particularly relating to zoom. Thus I decided to give Maplibre another look. I still try to avoid an NPM-related build pipeline though. For now.
For how to get map data, I redirect you to that first intro post. I assume you
now have a .pmtiles
file following Protomaps’ default format.
Vendor Necessary Libraries
As before, I want to be able to render the map entirely offline or self-hosted. As before, that is a matter of fetching the right JavaScript and CSS files. First, I get Maplibre’s JavaScript and CSS file via the unpkg CDN.
Note: The JS version of Maplibre is called “Maplibre GL JS”, they also have versions for native (mobile, desktop, …) and some other stuff.
wget https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js
wget https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css
Both of which I rename to include the version number (maplibre-gl.3.6.2.js
for example). By default, Maplibre does not know what to do with a Protomaps
source, so you also need the PMTiles JavaScript library (the js/
folder in there seems to hold the source). This one I rename in the same manner
to pmtiles.2.11.0.js
.
wget https://unpkg.com/pmtiles@2.11.0/dist/index.js
These three files I throw in a vendor/
folder.
Get a Style File
Specifying data sources and how to render them happens in a style JSON object
or file. To get going easily, I take a style file from Protomaps. At
https://maps.protomaps.com/ I get the light style by using “get style JSON”.
I save it as style.json
.
I make two changes to it: the url
line in sources
gets changed to
pmtiles://nearphilly.pmtiles
where nearphilly.pmtiles
is the PMTiles file I
have extracted (see intro post). Replace with whatever you got. I also change
the glyphs
line near the end of the file to have
vendor/fonts/{fontstack}/{range}.pbf
as value. See further down in this
section for more explanation on that.
As I understand it, you can also generate Protomaps’ default styles locally by following the instructions in the basemaps repository, but just getting it from them seemed simpler.
The style file follows this structure.
{
"version": 8,
"sources": {
"sourcename": {
"type": "vector",
"url": "pmtiles://path.pmtiles",
"attribution": "OSM Contributors and such"
},
"canhavemultiplesources": {}
},
"layers": [
{
"id": "nameforthelayer",
"type": "line,fill,symbol,raster,...",
"source": "whichsourcetoworkon",
"source-layer": "whichlayerinthatsourcetoconsider"
}
],
"glyphs": "vendor/fonts/{fontstack}/{range}.pbf"
}
The sources
describe where to find the data. You can use vector data, such as
a .pmtiles
file, but you can also use raster data, like a regular tile
server.
The layers
describe how to render the data. In the example some basic
keys are given, but there is also paint
, layout
, and some others. You can
look at the style file we downloaded for more examples or dig through the
Maplibre style specification for more info on that. Things are rendered in the
order they appear in the layers array. Later elements get rendered over earlier
ones. The file we got describes the layers as using data from a source named
protomaps
, so be sure to use that name when adding your .pmtiles
file as a
source.
Finally, glyphs
specifies where to find fonts. At the time of writing the
style file you just got already has a complete URL. I still want to make an
entirely offline solution though, so we fetch those individual files too. From
the URL given there, we can deduce the fonts can be gotten at
https://github.com/protomaps/basemaps-assets in the fonts
folder. So just
clone that repository (or download the .zip
file) and copy the fonts folder
out.
In the style example given above, I already updated the glyphs
key to match
how I store the fonts locally. In the vendor/
folder I have a fonts/
folder
and then in there a folder for every font filled with .pbf
files which hold
the actual characters for different Unicode ranges. Be sure to keep the
{fontstack}
and {range}
in the glyphs entry, Maplibre will fill those in as
needed.
If you go through the style file we downloaded, you will see some Noto fonts
mentioned: Noto Sans Italic, Medium, and Regular. So that vendor/
folder now
looks like this.
vendor
├── fonts
│ ├── Noto Sans Italic
│ │ ├── manymany.pbf
│ │ └── files.pbf
│ ├── Noto Sans Medium
│ │ └── andhere.pbf
│ └── Noto Sans Regular
│ └── andheretoo.pbf
├── maplibre-gl.3.6.2.css
├── maplibre-gl.3.6.2.js
└── pmtiles.2.11.0.js
The HTML Skeleton
Not much action happens here. I save this file as index.html
.
<!doctype html>
<html>
<head>
<script src="vendor/maplibre-gl.3.6.2.js"></script>
<link href="vendor/maplibre-gl.3.6.2.css" rel="stylesheet" />
<script src="vendor/pmtiles.2.11.0.js"></script>
<style>
body {
margin: 0;
}
#map {
height: 95vh;
width: 100%;
}
</style>
</head>
<body>
<div id="map"></div>
<script src="index.js"></script>
</body>
</html>
Tying It Together
Only one thing is still missing: the index.js
file mentioned in the
index.html
file. Nothing crazy happens here either, just calling the Maplibre
API to tell it how to handle pmtiles and then setting up the map.
// Enable pmtiles in maplibregl
let protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
let map = new maplibregl.Map({
// id of the div in HTML
container: "map",
zoom: 12,
// [lon, lat]
center: [-75.612, 40.0474],
// When changing map zoom and position, a hash is added to the URL. Loading
// the URL+hash bring you directly to that map zoom and position, ignoring
// the zoom and center specified here.
hash: true,
// You can do a 3D pitch of the map in Maplibre. 99.9% of the time I just
// triggered it accidentally and it bothers me.
pitchWithRotate: false,
// Your style file. Can also provide a JSON object.
style: "style.json",
});
// Adding controls I do after the map has loaded because sometimes it would have
// a race condition and error.
map.on("load", function () {
// Zoom in and out buttons
map.addControl(new maplibregl.NavigationControl());
// The little line in the bottom showing how long a metre (or somesuch) is on
// the map.
map.addControl(new maplibregl.ScaleControl({ unit: "metric" }));
// Button to make the map fullscreen
map.addControl(new maplibregl.FullscreenControl());
});
Running It
As mentioned in the intro post, you can use whatever capable-enough web server you have at your disposal. For this map related development, I have since switched to using Caddy. Running it for a folder can be done with
caddy file-server --debug --listen 127.0.0.1:2024 --root .
Again, I also provide a file with the necessities to get going, except for the PMTiles file. That you still have to get yourself. All the fonts are included too. See the included file for their license. Here is an overview of what is in the archive file.
Makefile
index.html
index.js
style.json
vendor
├── fonts
│ ├── Noto Sans Italic
│ │ └── many.pbf
│ ├── Noto Sans Medium
│ │ └── many.pbf
│ ├── Noto Sans Regular
│ │ └── many.pbf
│ └── OFL.txt
├── maplibre-gl.3.6.2.css
├── maplibre-gl.3.6.2.js
└── pmtiles.2.11.0.js