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)