Offline Maps with Protomaps in Leaflet
I like my maps and I like them offline (or self hosted) when needed. Protomaps provides a nice solution to this goal. I do not have a one click solution for you, but once things are set up, it seems to work quite well. This approach uses:
- A
.pmtiles
file that holds the actual data, brought to you by protomaps. - The Leaflet JavaScript library to show things in a browser.
- Your favourite internet browser (sorry, no text-based one for this post).
- A web server of some sorts (which you are still running locally). I opted for nginx in Docker.
Getting the Map Data
Protomaps provides example builds of the entire world. At the time of writing that is about 107GB which is not huge, but probably also a bit much for you to just play around with. If that is fine, of course go ahead and download it. For future reference, they describe this as being “version 3”. I do not know how much difference there is between versions of their format.
Another option is to only use a section of the map. For that, Protomaps provides the go-pmtiles tool. With it, you can extract an area of your liking, without even needing to download that entire file.
pmtiles extract BUILDURL TARGETFILE --bbox==COORDS
The BUILDURL
is a specific file from their example builds. The TARGETFILE
is how you want the file to be named locally. The bounding box coordinates are
of the format NWlon
,NWlat
,SElon
,SElat
. For example, filling it in with
the area west of Philadelphia gives:
pmtiles extract https://build.protomaps.com/20231025.pmtiles nearphilly.pmtiles --bbox=-75.8179,40.1068,-75.3770,39.9217
You can verify the resulting file at https://protomaps.github.io/PMTiles/. Which will not look pretty, but will show you the layers in the file and show on the map the data contained in it. With the “show attributes” checkbox in the top right, you can see more data on hovering over the map. As for the looks, no worries, our final map will look nicer than that.
Showing the Map Data
The most straightforward way (because others have solved this problem for you) to show this map data is by using a browser. Here I opt to use the Leaflet library because I am more familiar with it and because the Maplibre GL related solution in the Protomaps documentation talks about using NPM and such. If I can avoid that entire messy stack, I definitely will. That said, you still need to get a hold of the correct JavaScript libraries, so if you do have enough experience with NPM and prefer it, knock yourself out.
Download the JavaScript Libraries
In the Leaflet Quick Start Guide, they let you use a CDN
hosting the JavaScript and CSS files to get started with Leaflet. Works for us,
just download those two files. At the time of writing, this suffices. I rename
the files to leaflet.1.9.4.js
and leaflet.1.9.4.css
. In line with the
version number I download.
wget --output-document leaflet.1.9.4.css https://unpkg.com/leaflet@1.9.4/dist/leaflet.css
wget --output-document leaflet.1.9.4.js https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
For the Protomaps connection, I go for the vector approach, not the raster one.
Their documentation lists both options. To make it
work, you need the protomaps-leaflet library, not the pmtiles
library. Protomaps-leaflet depends on pmtiles, but already includes it. Here
too there is a CDN link mentioned, though the version in the documentation does
not necessarily match the current version. There also seems to be some
confusion between a protomaps
and a protomaps-leaflet
package on NPM. The
current latest version is 1.24.0
and the correct package name seems to be
protomaps-leaflet
. From it, you want to get the following file. This one too
I rename to indicate the version number. So that ends up looking like this.
wget --output-document protomaps-leaflet.1.24.0.min.js https://unpkg.com/protomaps-leaflet@1.24.0/dist/protomaps-leaflet.min.js
An earlier version of this post erroneously said to also download
protomaps.min.js
, but this is wrong.
A HTML File Binding It Together
I will just give the HTML file with comments in it that hopefully explain
enough. Save this as index.html
in the same folder as all the other things
you have downloaded so far.
<!doctype html>
<html>
<head>
<!-- The Leaflet library -->
<link rel="stylesheet" href="leaflet.1.9.4.css" />
<script src="leaflet.1.9.4.js"></script>
<!-- The Protomaps connection -->
<script src="protomaps-leaflet.1.24.0.min.js"></script>
<style>
#map {
/* Decide how wide the map should be */
width: 1000px;
/* For some reason 100vh (height of entire viewport) gave me a scrollbar */
height: 95vh;
/* Set lighter background for areas without features. Overrides
leaflet's internal CSS */
background: #fffbf6;
}
</style>
</head>
<body>
<!-- Required for leaflet -->
<div id="map"></div>
<!-- All the code actually setting up the map -->
<script>
// Set your configuration
let config = {
// The `pmtiles` file you extracted
file: "nearphilly.pmtiles",
// The center that should be shown on the map. [lat, lon] format
loc: [40.0315, -75.5145],
// How much to be zoomed in at the start (higher number = more zoomed in)
zoom: 14,
};
// Tell Leaflet to create a map
const map = L.map("map");
// Tell Leaflet where to center
map.setView(config.loc, config.zoom);
// Tell Protomaps to create a layer for leaflet
var layer = protomapsL.leafletLayer({
url: config.file,
// Not specifying paint_rules and label_rules => use the default (light).
// Note that protomapsL.paintRules and protomapsL.labelRules create the
// actual rules.
//
// Light theme
// paint_rules: protomapsL.paintRules(protomapsL.light, ""),
// label_rules: protomapsL.labelRules(protomapsL.light, ""),
// Dark theme
// paint_rules: protomapsL.paintRules(protomapsL.dark, ""),
// label_rules: protomapsL.labelRules(protomapsL.dark, ""),
});
// Add the layer to the map
layer.addTo(map);
// Run this to enable clicking on the map and seeing the exact data that
// was in the `pmtiles` file for that location.
// layer.addInspector(map);
</script>
</body>
</html>
Serving the Files
You cannot just open index.html
in a browser and hope it will work. Sadly,
you have to have some sort of web serving going on. This will still be running
entirely local, but it is some more extra effort to go through. Usually I use
Python’s http.server
for this purpose, but it does not support the HTTP Byte
Serving that the Protomaps approach requires.
I opt for Docker to come to the rescue. Specifically, having it run a container with the nginx webserver. We then just add our folder as a volume in that container. If you have other ways to run a decent enough web server, go crazy with those of course.
There is already an image for nginx on Docker Hub, so we can use that directly.
Running the server from our folder can be done with the following command. I
add the current folder as a volume in the default folder nginx serves files
from. I connect port 8080 on our host to port 80 in the container. I name the
container offline-map-host
. I run it -d
, detached. I also specify the
version of nginx (the image) I am using.
docker run --rm --volume .:/usr/share/nginx/html -p 8080:80 --name offline-map-host -d nginx:1.25-bookworm
Open http://localhost:8080 in your browser to finally see your map. Run the following to stop the web server.
docker stop offline-map-host
I combine those two commands in a Makefile
for easier running, but whether
you care about that is up to you.
Final
Here is an archive with the files we used in this post. Not
included is the nearphilly.pmtiles
file due to its size. It is still only 8.4
MB, but compared to the rest (a few 100 kB) it adds up. You will have to
extract that yourself. The contents should be
.
├── Makefile
├── index.html
├── leaflet.1.9.4.css
├── leaflet.1.9.4.js
└── protomaps-leaflet.1.24.0.min.js
I find the documentation not that great, but once you figure some things out, Protomaps makes it easy enough to have a map entirely self hosted. This blog post was going to be part of a much larger post that I started months ago, but never finished due to it tackling so many problems at once (and life got a bit busy). Instead I decided to get this part out, hoping I can get around to slicing off the other parts and posting them piece by piece. Ideas that were important to me include styling the map and having more control of what data is put into the pmtiles files. Watch this space! (But maybe not too closely in case I do not get around to it)