Interactive maps with static vector tiles

September 26, 2020

This post aims to be a practical guide to pre-build and serve vector tiles as static assets for interactive maps without the need for a dedicated server or a third-party service.

If you're in a hurry or you'd rather learn by example, here's the source code for a demo project that implements everything explained here.

Steps

  1. Assess if serving static tiles is right for you
  2. Find and download data
  3. Convert your data to GeoJSON
  4. Build static tiles
  5. Add necessary metadata
  6. Add styles
  7. Host the tiles
  8. Consume your tiles

Assess if serving static tiles is right for you

Statically generated content (like this blog) comes with advantages both in costs, resources, and security:

However, pre-generating tiles also has limitations:

Serving static content works in a fair amount of cases, but is not well suited for everyone, and before moving forward it's worth asking the question: will it work for you?

Find and download data

If you know what you need to display in the map, but you don't have the data yet, the next step is finding a good data source.

A web search is your best ally, but here's an incomplete list of sources with a wealth of information:

Before using the data, please make sure to comply to the terms of use and attributions of each source.

Convert your data to GeoJSON

There are different formats to store Geographic information data but the tool you're going to use in the next step requires the input data to be in the GeoJSON format.

To convert the data to GeoJSON you can use any online service or tool of your choice.

GDAL (ogr2ogr) is my tool of choice because it allows you to convert practically between any format you can find in the wild. Once you have installed it, converting the data is a matter of running:

ogr2ogr -f GeoJSON source.geojson source.shp

Build static tiles

tippecanoe is a command line application that allows you to generate vector tiles from GeoJSON files, it's capable of generating:

The file hierarchy is convenient to serve static tiles because clients commonly use URL structures in the form of http://server.com/z/x/y.pbf to request tiles.

The idea is roughly as follows:

  1. Generate an .mbtiles file for every layer of data that you want to show. At this stage, you can adjust the placement and visibility of this layer at different zoom levels.
  2. Combine the .mbtiles files and generate the file hierarchy we described above using the tile-join command (included when you install tippecanoe)
# Generate a .mbtiles file for every layer you want to display
# this shows countries at low zoom levels but states at higher zoom levels:
tippecanoe --exclude-all -z3 -o countries-z3.mbtiles --coalesce-densest-as-needed countries.geojson
tippecanoe -zg -Z4 -o states-Z4.mbtiles --coalesce-densest-as-needed --extend-zooms-if-still-dropping states.geojson

# Join the generated files and Create a nested directory of tiles
tile-join -e tiles countries-z3.mbtiles states-Z4.mbtiles

Don't let the flags threaten you, tippecanoe's README contains a cookbook with examples, chances are you will find something useful there.

This step will vary from project to project depending on what information you need to display.

Add necessary metadata

Along with the tiles, tippecanoe generates a metadata.json with information about the tiles and the layers used to generate them.

The client consuming the tiles uses this file later on, but for it to be usable you need to do some cleanup:

  1. Set the URL in which you're going to host your tiles.
  2. Convert the string value of the vector_layers key into JSON.
  3. Delete the json property.

You can do this manually, or with a bit of glue code, here's a bit of JavaScript, feel free to borrow, modify and use it:

const metadata = require("./tiles/metadata.json");
const { writeFileSync, copyFileSync } = require("fs");

// make a backup of the data in case something goes wrong
copyFileSync("./tiles/metadata.json", "./tiles/metadata.backup.json");

// convert the `vector_layers` key from a string to JSON
metadata.vector_layers = JSON.parse(metadata.json).vector_layers;

// Set the tiles key
metadata.tiles = ["https://your-domain.com/tiles/{z}/{x}/{y}.pbf"];

// Remove the `json` key to prevent errors
delete metadata.json;

// Write the updated file
writeFileSync("./tiles/metadata.json", JSON.stringify(metadata));

Host the tiles

Choosing where to host the tiles is entirely up to you, as it will depend on your infrastructure, but here are some recommendations:

Add styles

From the MapBox documentation:

A Mapbox style is a document that defines the visual appearance of a map: what data to draw, the order to draw it in, and how to style the data when drawing it. A style document is a JSON object with specific root level and nested properties. This specification defines and describes these properties.

It's important to highlight that despite what the word 'styles' might suggest, this JSON document also indicates what data to draw.

If you want to start from scratch to keep things light, here is base template you can use:

{
"version": 8,
"name": "Your Theme",
"sources": {
"tiles": {
"type": "vector",
"url": "https://your-domain.com/tiles/metadata.json"
}
},
"layers": [
{
"id": "country-fills",
"source": "tiles",
"source-layer": "countries",
"type": "fill",
"paint": {
"fill-color": "#E6ECF2"
}
}
]
}

Key points to note:

The style document can be as complex or as simple as your application needs, and can include expressions to compute the value for different attributes.

Consume your tiles

The final step of the puzzle is to consume your vector tiles with a client library, from plugins for the popular Leaflet, to native clients.

The people at MapBox keep an updated list at github.com/awesome-vector-tiles.

Whatever you choose will depend on your needs, but just so you have a reference, consuming the generated tiles using mapbox-gl-js is as simple as providing a link to your styles when instantiating a new map:

new Mapbox({
container: "map",
style: "https://your-domain.com/style.json",
});