Offline maps with vector tiles


On a recent project we wanted to have high quality mapping, but completely offline.

Most map providers are online-only, and some permit temporary download as a cache.

In my case the whole dataset needed to be downloaded in advance, as the device I’m working with will not be connected to the internet.

I decided on the map tiler datasets, they provide various extracts of open street map, as vector tiles, and satellite imagery as well.

The data provided as a single ‘mbtiles’ file. This is actually a SQLite database, containing vector or raster tiles of your selected region, and also a high level layer of the entire world.

Map tiler provide instructions and a docker image for serving the tiles. This will convert the vector tiles into PNG raster images.

I didn’t want to go with this solution, my hardware is highly constrained (a Raspberry Pi) so serving raw vector tiles and rendering them in the browser on-the-fly is preferable. It also provides a better user experience.

Whilst I say I’m offline, to clarify this is a web application (browser application and web node.js web server) which is not connected to the internet.

Serving MBTiles

I have a node.js TypeScript application running on my device already. So the first task is to provide an endpoint that will serve vector tiles with express.

The @mapbox/mbtiles package will open the mbtiles file and retrieve the vector tiles. So this just needs connecting to the http request.

I wrote a simple module which wraps @mapbox/mbtiles. It open the mbtiles file and returns a function to retrieve a tile using promises:

import MBTiles from '@mapbox/mbtiles'

export interface ITileGetter {
  getTile: (
    x: number,
    y: number,
    z: number
  ) => Promise<{ headers: any; data: Buffer }>
}

const openVectorTiles = (file: string) => {
  return new Promise<ITileGetter>((resolve, reject) => {
    new MBTiles(`${file}?mode=ro`, (err, tiles) => {
      if (err) return reject(err)

      const getTile = (
        x: number,
        y: number,
        z: number
      ): Promise<{ headers: any; data: Buffer }> => {
        return new Promise((resolveTile, rejectTile) => {
          tiles.getTile(z, x, y, (err, data, headers) => {
            if (err) return rejectTile(err)
            return resolveTile({
              data,
              headers
            })
          })
        })
      }

      resolve({ getTile })
    })
  })
}

export default openVectorTiles

You can then connect this to express like so:

import openVectorTiles, { ITileGetter } from './tiles/index'

// you should wait until you open this file before opening the http port
const vectorTiles = await openVectorTiles('./vector-tiles.mbtiles')

// express initialisation omitted

app.get('/tile/:z/:x/:y', async (req, res) => {
  try {
    const { data, headers } = await vectorTiles.getTile(
      parseInt(req.params.x),
      parseInt(req.params.y),
      parseInt(req.params.z)
    )
    Object.keys(headers).forEach(key => res.setHeader(key, headers[key]))
    res.send(data)
  } catch (err) {
    res.status(404).send()
  }
})

Note that an error is thrown when a tile doesn’t exist, which is why we return a 404 in this case.

This code will serve any kind of mbtiles file, regardless of whether it contains raster or vector tiles.

Rendering Raster Tiles

Rendering the raster tiles on a web page using OpenLayers is simple, just a matter of registering a Tile layer which points to our express endpoint.

import Map from 'ol/Map'
import TileLayer from 'ol/layer/Tile'

const layer = new TileLayer({
  source: new XYZ({
    url: '/tile/{z}/{x}/{y}'
  })
})

const map = new Map({
  target: 'map', // this points to a <div id="map"/> element
  layers: [ layer ],
  view: new ol.View()
});

Rendering Vector Tiles

Vector tiles require some hackery. Vector tiles contain just vector data, so they need to be rendered in the browser. OpenLayers can do this rendering, but it needs configuration to tell it how different features in the vector tile should be drawn. i.e. what colours, line thicknesses, fills etc… should be used.

This is taken care of in the ol-mapbox-style package. This package gives you a function which will return you a map, preconfigured with your vector layer added with the correct styling set up. The documentation offers an alternative to this where it provides just a styling function, but I couldn’t get this to work.

So our map is now initialised like this:

import Map from 'ol/Map'
import olms from 'ol-mapbox-style'

olms('map', `/styles.json`)
  .then((map: Map) => {
    // you get your map instance here
  })

I don’t particularly like this approach, but it seems it’s the best we can do.

The styles.json file is loaded at runtime, and contains the styling information for the map. Several versions of these file can be downloaded from the map tiler website (once you have an account) but I couldn’t get these to work.

I figured out the files I needed from reverse engineering one of the example maps. I’ll save you this inconvencience with this zip file, which contains the styles.json file for the ‘bright’ map scheme, and the associated assets.

styles.zip

Summary

Offline mapping is not a mainstream usecase, but it can be done. Raster maps stored in an mbtiles file is a convenient way to serve and render maps. Using vector tiles rendered in the browser requires a bit more hackery, and I hope better support will one day be provided in OpenLayers to make this easier.