Blog

Generate vector tiles with built-in postgis functions

This blog post explores the possibility of generating, caching and serving vector tiles on the fly, from a PostgreSQL database, using built-in PostGIS functions ST_AsMVT and ST_MakeEnvelope.

1. Setup a test database

Let us begin by firing off a db instance. We use docker for this because of its easily reproducible and disposable nature. We also use docker-compose because we want to be able to orchestrate multiple containers such as database, caching and other services. For now, we will get started with a single docker image which gives us a Postgres instance with PostGIS extension installed.

File: docker-compose.yml

version: '3'
services:
  db:
    image: mdillon/postgis
    ports:
      - "5432:5432"
    environment:
      - "POSTGRES_PASSWORD=pass"

Note: The database password here is extremely simple. Make sure you use something more complex if running this code outside of local development environment.

To test the compose file, execute: docker-compose config -q and to fire up the containers, run the command: docker-compose up -d:

2. Import some test data

Now that we’ve a postgres instance up and running, we can begin importing some data.

Create a database and initialize the POSTGIS extension:

docker-compose exec db psql -U postgres -c 'CREATE DATABASE roads'
docker-compose exec db psql -U postgres roads -c 'CREATE EXTENSION POSTGIS'

I exported road data of the Pune city, the city I live in, and hosted it on the github repo:

Download and extract test data [1]:

wget https://github.com/androidfanatic/tiles_with_st_asmvt/raw/master/data/pune_roads.tar.gz
tar -xvf pune_roads.tar.gz

This will extract a GeoJSON file in the current directory. We want to be able to import this data into our database. In comes GDAL. I call it the swiss-knife of GIS data. GDAL allows one to work with GIS datasets of all sizes and formats and offers wide variety of features for data translation. Head over to https://www.gdal.org/ for quick installation steps and to read more about it.

Once installed, we can begin importing the data from the GeoJSON file into our database with this simple one-liner:

ogr2ogr -f "PostgreSQL" PG:"dbname=roads user=postgres host=localhost password=pass" \
  "pune_roads.geojson" -nln pune_roads -append \
  -t_srs "http://spatialreference.org/ref/epsg/4326/" -overwrite

Let’s also verify that the data was imported.

docker-compose exec db psql -U postgres roads -c "SELECT COUNT(*) FROM pune_roads"

Now that we’ve the test database setup with some test data seeded in, let’s work on generting the vector tiles on the fly.

3. Create a tile service

To display the data back, we begin by creating a HTTP service that returns vector tiles. To do so, we use a combination of ST_AsMVT, ST_AsMVTGeom and ST_MakeEnvelope functions provided by PostGIS.

Here’s a NodeJS script, inspired by Chris’s blog post [2]:

The interesting bit here is the translation of /z/x/y parameters to a bounding box that gets consumed by the function ST_MakeEnvelope(). We also set clipping = True for the function ST_AsMVTGeom and select additional database columns (id, name and highway in this case) that get translated to feature properties.

These feature properties can be used for data-driven styling [3], displaying additional information on map and more.

File: app.js

// mercator
const SphericalMercator = require('@mapbox/sphericalmercator');
const mercator = new SphericalMercator({size: 256});

// database library
const {Client} = require('pg')
const db = new Client('postgres://postgres:pass@localhost:5432/roads');
db.connect();

// http server
const express = require('express');
const app = express();

app.use(express.static(__dirname + '/public'));

// route
const layerName = 'pune_roads';
app.get(`/tiles/${layerName}/:z/:x/:y.mvt`, async (req, res) => {
  const bbox = mercator.bbox(req.params.x, req.params.y, req.params.z, false);
  const query = `
      SELECT ST_AsMVT(q, '${layerName}', 4096, 'geom') FROM (
        SELECT 
          id, name, highway,
          ST_AsMVTGeom(
            wkb_geometry,
            ST_MakeEnvelope(${bbox[0]}, ${bbox[1]}, ${bbox[2]}, ${bbox[3]}, 4326),
            4096,
            256,
            true
          ) geom FROM pune_roads WHERE highway IS NOT NULL
        ) q
    `;
  try {
    const tiles = await db.query(query);
    const tile = tiles.rows[0];
    res.setHeader('Content-Type', 'application/x-protobuf');
    if (tile.st_asmvt.length === 0) {
      res.status(204);
    }
    res.send(tile.st_asmvt);
  } catch (err) {
    res.status(404).send({ error: err.toString() });
  }
});

app.listen(8080);

4. Add the new vector layer

Let’s add the tile layer to the map. We used mapbox [4] which is our web map of choice because of it’s performance, data-sets and robustness when it comes to configuring and styling the map.

Add the layer to map with this snippet:

map.addLayer({
  "id": "road",
  "source-layer": "pune_roads",
  "source": {
    "type": "vector",
    "tiles": ["http://localhost:8080/tiles/pune_roads/{z}/{x}/{y}.mvt"],
    "tolerance": 0,
  },
  "type": "line",
  "paint": {
    "line-color": "orangered",
    "line-width": ['step', ['zoom'], 0.4, 14, 1, 16, 2, 18, 4, 20, 8]
  }
});

At this point, you should be able to see road data being rendered on the map.

Next steps to work on and explore:

  • Cache tiles for sometime, say an hour, instead of re-generating again
  • Allow pre-seeding and invalidation of cached tiles

You can find the entire source code, with caching, here at: https://github.com/androidfanatic/tiles_with_st_asmvt

References:

[1] Road data source: https://overpass-turbo.eu/

[2] https://medium.com/nycplanninglabs/using-the-new-mvt-function-in-postgis-75f8addc1d68

[3] https://docs.mapbox.com/mapbox-gl-js/example/data-driven-circle-colors/

[4] https://docs.mapbox.com/mapbox-gl-js/api/