Hillshading on my Static Maps

In a previous article I wrote about adding maps to my blog, which is a static site. I use these maps to write about walks I’ve done. These maps can be dragged, rotated and zoomed, but there is no backend tileserver supporting it, it all comes from a single static file. This is thanks to the PMTiles format and support by the frontend renderer maplibre-gl.

In the original article I took a pre-made pmtile file of the world from the Protomaps project and extracted a smaller part using the pmtiles CLI. Later I ended up making my own pmtiles directly from OpenStreetMap data, which I’ll describe below. This provides a lot of geographic data like coastlines, forests, roads etc., but what I really wanted was hillshading, which I’ve now figured out.

Making custom PMTiles

First of all I’ll talk about making custom PMTiles. OpenStreetMap exports can be fetched from Geofabrik in PBF format. Rather than download the whole world you can download just the region you need. To convert this into a pmtiles file I use Planetiler like so:

1java -Xmx1g -jar ~/java/planetiler.jar --download \
2     --osm-path=scotland-latest.osm.pbf \
3     --output scotland-latest.osm.pmtiles

The --download option does some magic to download and include extra data sources like coastlines etc. At this point a smaller region can be extracted with the pmtiles tool as in the first article:

1pmtiles extract scotland-latest.osm.pmtiles whw.pmtiles \
2        --bbox=-5.11,56.82,-3.15,55.94

For this data we need to use a different style, I went for osm-bright-gl-style. To load this into maplibre-gl looks like this:

 1const response = await fetch("/osm-bright-gl-style/style.json");
 2const style = await response.json();
 3const map = new maplibregl.Map({
 4  container: e,
 5  style: {
 6    version: 8,
 7    sprite: `${window.location.origin}/osm-bright-gl-style/sprite`,
 8    glyphs: `${window.location.origin}/osm-bright-gl-style/{fontstack}/{range}.pbf`,
 9    sources: {
10      openmaptiles: {
11        type: "vector",
12        url: `pmtiles://${tilesUrl}`,
13        attribution: attr,
14      },
15    },
16    layers: style.layers,
17  },
18});

Feel free to check out the source of my blog to see more: https://github.com/georgek/blog/blob/master/assets/js/map.js

Making Hillshade PMTiles

Initially I searched for hillshade data in PMTiles format, but was only able to find a few low quality sources, so I gave up. But more recently I happened upon Sonny’s LiDAR models. Initially looking at this it didn’t make much sense, but it promised data that was free to use, so my interest was piqued.

Before I continue, I must give a disclaimer: I don’t fully understand this and this will not teach you what all this means. This is just what worked for me.

Assuming your region of interest is covered by Sonny’s data then what you want is the ‘DTM 1"’ data. It’s hosted on Google Drive which is a little clunky, and it comes in separate tiles, of which you might need more than one. Each region gives an overview which should enable you to find which individual file(s) you need.

After downloading and unzipping, you should end up with one or more .hgt files. For example, for Gran Canaria I have N27W016.hgt and N28W016.hgt. The first thing to do is convert them to tiff using GDAL:

1for f in *.hgt; do
2    gdal_translate $f $(basename $f .hgt).tiff
3done

Next I use Rasterio to merge them into one file, and clip it to a bounding box:

1rio merge *.tiff \
2    --bounds -16.273499,27.508271,-14.889221,28.386568 \
3    -o gran-canaria.tiff

The next part took a long while to figure out. We want to convert it to mbtiles format, but the tiff currently isn’t in quite the right format expected by maplibre-gl, but this can be converted into the right mbtiles format using rio-rgbify:

1rio rgbify --format webp --min-z 0 --max-z 14 \
2    gran-canaria.tiff gran-canaria.mbtiles

Note the max zoom setting --max-z. Increasing this will dramatically increase both the processing time and the file size of the output. 16 is great, but even for a small island like this the file size will be large (176 MiB). I’ve found 14–15 a reasonable compromise.

This can finally be converted into pmtiles using the pmtiles CLI:

1pmtiles convert gran-canaria.mbtiles gran-canaria.pmtiles

At this point it’s easy to add the source and layer to maplibre-gl:

 1map.addSource("relief", {
 2    type: "raster-dem",
 3    url: `pmtiles://${e.dataset.reliefTilesUrl}`,
 4    encoding: "mapbox",
 5    tileSize: 512,
 6    attribution: '<a href="https://sonny.4lima.de/">Sonny</a>',
 7});
 8map.addLayer(
 9    {
10        id: "relief",
11        source: "relief",
12        type: "hillshade",
13        paint: {
14            "hillshade-accent-color": "#004400",
15            "hillshade-highlight-color": "#ddffdd",
16            "hillshade-exaggeration": 1,
17        },
18    },
19    "water",
20);

Conclusion

This almost certainly isn’t the best way to do this and I don’t really know what I’m doing. But it worked and I’m pleased. GIS data seems like a massive rabbit hole and there doesn’t seem to be a great amount of easily-accessible information out there about it. But it’s getting better. A few years ago I wouldn’t have imagined I could do this without using a 3rd party service. But here it is on a static site!