Custom Static Vector Maps on your Hugo Static Site

This blog is a static site built with Hugo. Being static means it can be served from a basic, standard (you might say stupid) web server with no server-side scripting at all. In fact, this blog is currently hosted on Github Pages, but it could be anywhere.

Up until now, if you wanted to include an interactive map on a static site you were limited to using an external service like Google Maps or Mapbox and embedding their JS into your page. This would then call to their non-static backend service to produce some kind of tiles for your frontend.

But we can now put truly static maps into a static site. Behold!

This isn’t coming from a backend tile server. This is all completely static, it’s all hosted on GitHub Pages and the above map uses less than 2 MiB of storage. What’s more it’s really quite easy to get started. Let’s see how it’s done.

Although I’m using Hugo as a concrete example below, all of this should be easily translatable to any static site.

Generating a PMTiles basemap

The magic here starts with Protomaps and the PMTiles format. PMTiles is an archive format for tile data which is designed to be accessed with HTTP range requests. As long as the backend server supports HTTP range requests1 then the client can figure out which requests to make to get the tiles it needs.

This means our map data can be hosted anywhere, just like our static site.

You can create a PMTiles archive from raw map data (such as OpenStreetMap), but the easiest way is to extract data from an existing archive. The Protomaps project produces daily builds of the entire world from OSM data. These files are over 100 GiB but you can extract a much smaller file without downloading the whole thing.

First download the latest release of go-pmtiles from GitHub for your platform and extract it somewhere (preferably somewhere on your PATH like perhaps ~/.local/bin).

Next you need to calculate a bounding box for your extract. I used bboxfinder.com. Draw a rectangle then copy the box at the bottom. It should look something like -16.273499,27.508271,-14.889221,28.386568.

Make sure you keep a note of this bounding box for later!

Now, using pmtiles that you just installed, you can create your extract like so:

1pmtiles extract \
2        https://build.protomaps.com/20231001.pmtiles \
3        mymap.pmtiles \
4        --bbox=-16.273499,27.508271,-14.889221,28.386568

You can test your basemap by visiting https://protomaps.github.io/PMTiles/ and selecting your newly created pmtiles file.

Finally, put your PMTiles file into your Hugo static directory, for example static/mymap.pmtiles.

MapLibre GL

Now you have a PMTiles extract you’re happy with we need to render it somehow. For this we can use maplibre-gl.

If you haven’t already, in your Hugo project directory initialise an npm project:

1npm init

Now install the required packages:

1npm install pmtiles
2npm install maplibre-gl
3npm install protomaps-themes-base

Now add the following as a JavaScript asset at assets/js/map.js:

 1import * as pmtiles from "pmtiles";
 2import * as maplibregl from "maplibre-gl";
 3import layers from 'protomaps-themes-base';
 4
 5let protocol = new pmtiles.Protocol();
 6maplibregl.addProtocol("pmtiles",protocol.tile);
 7
 8function makeMap({tilesUrl, bounds, maxBounds, container = "map"}) {
 9    var map = new maplibregl.Map({
10        container: container,
11        style: {
12            version: 8,
13            glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf',
14            sources: {
15                "protomaps": {
16                    type: "vector",
17                    url: `pmtiles://${tilesUrl}`,
18                    attribution: '<a href="https://protomaps.com">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>'
19                }
20            },
21            layers: layers("protomaps","light")
22        },
23        bounds: bounds,
24        maxBounds: maxBounds,
25    });
26    return map;
27}
28
29document.addEventListener('DOMContentLoaded', function(){
30    document.querySelectorAll("div.map").forEach((e) => {
31        makeMap({
32            tilesUrl: e.dataset.tilesUrl,
33            bounds: e.dataset.bounds.split(",").map(parseFloat),
34            maxBounds: e.dataset.maxBounds.split(",").map(parseFloat),
35            container: e,
36        });
37    });
38});

What this does is finds every div on your page with the class map and creates a maplibre-gl map there. It expects the div.map elements to have data attributes which it uses to set up the map. Each div should look like this:

1<div class="map"
2     data-tiles-url="mymap.pmtiles"
3     data-bounds="-16.273499,27.508271,-14.889221,28.386568"
4     data-max-bounds="-16.273499,27.508271,-14.889221,28.386568"
5</div>

The bounds are what you saved earlier from running pmtiles. You should definitely set max-bounds the same as your original bbox, but you can set bounds smaller, like I have (bounds is the default zoom, maxBounds is the maximum span of the map).

Now let’s put it all together with Hugo.

Building with Hugo

This section is quite dependent on your site and theme set up, so I can’t give specifics, but I hope you already have an idea of where to put CSS or JavaScript etc. Some themes include provision for an extra-head.html or similar that you can put in layouts/partials.2

JavaScript bundle

Most of the work will be done by the JavaScript above, but we first need to bundle and include it in our pages. This is done using Hugo Pipes.3 Put the following in the <head> section of your site, near other scripts:

1{{ $jsBundle := resources.Get "js/map.js" | js.Build "js/mapbundle.js" | minify | fingerprint }}
2<script defer src="{{ $jsBundle.Permalink }}" integrity="{{ $jsBundle.Data.Integrity }}"></script>

CSS

You’ll need a couple of bits of CSS, first we need to style the div.map elements with some sensible default at least, so add the following to a style sheet:

1div.map {
2    width: 100%;
3    height: 500px;
4    margin-bottom: 1rem;
5}

You also need maplibgre-gl’s style. First mount the stylesheet from node_modules in Hugo’s assets by adding to your Hugo config:

1module:
2  mounts:
3    - source: "assets"
4      target: "assets"
5    - source: "node_modules/maplibre-gl/dist/maplibre-gl.css"
6      target: "assets/css/maplibre-gl.css"

Do not forget the default mount for assets. Now in your <head> section add the stylesheet:

1{{ $style := resources.Get "css/maplibre-gl.css" | fingerprint }}
2<link rel="stylesheet" href="{{ $style.Permalink }}">

Hugo shortcode

To insert the div.map element into your markdown posts you’ll need a shortcode. Put the following in layouts/shortcodes/map.html:

1<div class="map"
2     data-tiles-url="{{ .Get "tiles-url" }}"
3     data-bounds="{{ .Get "bounds" }}"
4     data-max-bounds="{{ .Get "max-bounds" }}">
5</div>

Now you can simply use the shortcode anywhere in your site like so:

1{{<map tiles-url="/gran-canaria2.pmtiles" bounds="-15.923996,27.713926,-15.308075,28.205793" max-bounds="-16.273499,27.508271,-14.889221,28.386568">}}

Conclusion

I can’t believe how easy this has been for me to set up. Here’s to Protomaps, MapLibre GL and, of course, OpenStreetMap!

I had previously tried setting up my own custom maps and found it quite difficult to get started, not to mention requiring me to run a special tileserver somewhere or use a third party service. I’m by no means a map expert (although I am an OpenStreetMap contributor of many years, if that means anything), so I find this post a testament to how far the work of the free/open mapping community has come.

Of course, this approach isn’t suitable for everything and comes with drawbacks. In particular, your map will never receive updates unless you update the pmtiles file. This could be particularly bad if your area doesn’t have good OpenStreetMap coverage.

But, for me, this is static by design. I want these pages to be static, including the map. If I include a route showing where I walked, it doesn’t make sense for it to appear on some map of the future. It should be a map of the past.

Also, let’s not forget that maps don’t have to contain “real” data. It could contain a planned development or even just a fantasy world. There are many possibilities. Next on my list to play is to try to get hillshading/relief into my maps.

To finish, just for fun, here’s another map showing a recent multi-day walk across Gran Canaria4:

Appendix

org-mode and ox-hugo

I don’t write my blog in Markdown directly, but in org-mode first and use ox-hugo to export it. There are a few ways to add shortcodes, but the neatest I’ve found for the map shortcodes is simply:

1#+hugo: {{<map tiles-url="/bangor.pmtiles" bounds="-4.178753,53.215670,-4.137597,53.231163" max-bounds="-4.199352,53.210916,-4.116955,53.235941">}}

  1. Most do, but not all. Notably I found the dev server used by the Parcel bundler does not, which led to much head scratching. ↩︎

  2. Overriding a theme is quite easy with Hugo, see: https://bwaycer.github.io/hugo_tutorial.hugo/themes/customizing/ ↩︎

  3. If you are unfamiliar with Hugo Pipes you can read all about it here↩︎

  4. I’ve used maplibre-gl-vector-text-protocol to add statically hosted GPX files to the map. See the source of my blog to see how. ↩︎