I currently work at a Japanese mapping startup called Geolonia. We specialize in customizing and displaying geospatial data, and a big part of this is making vector tiles. There are a lot of ways to do this, the most popular being tippecanoe from Mapbox, ST_AsMVT in PostGIS, OpenMapTiles, and tilemaker. We normally use tippecanoe to convert GeoJSON data to vector tiles and tilemaker to generate OpenStreetMap-based tiles, but there isn’t really an easy way to generate arbitrary vector tiles in plain old TypeScript / JavaScript.
Recently, I’ve been working on a proxy for converting data directly to vector tiles on the fly, and that is exactly what I need to do — generate arbitrary tiles without relying on outside processes like tippecanoe or PostGIS (this specific proxy will be running in AWS Lambda).
The Mapbox Vector Tile (MVT) spec is the de-facto standard for delivering tiled vector data, and that’s what we’ll be using. MVT tiles are Protocol Buffers-encoded files that include the drawing instructions for map renderers. The MVT spec handily includes the protobuf definition file, so we can use this to compile to JavaScript and generate the TypeScript definition files.
For the following, I’m assuming you have a git repository with the basic JavaScript and TypeScript essentials already set up.
Working with the MVT format in TypeScript
First, we create a submodule to bring in the MVT protobuf definition.
git submodule add https://github.com/mapbox/vector-tile-spec.git
This should create a new directory vector-tile-spec
, with the contents of the MVT vector tile specification repository inside. Now, we can set up the protobuf-to-JS compiler.
My project uses ES modules, so these instructions will generate ES modules, but if you need something else, check out the protobufjs documentation.
In package.json, create a new script called build:protobuf
:
"scripts": { ... "build:protobuf": "pbjs -t static-module -w es6 -o src/libs/protobuf.js vector-tile-spec/2.1/vector_tile.proto && pbts -o src/libs/protobuf.d.ts src/libs/protobuf.js" }
Install protobufjs
:
npm install --save protobufjs
Now, we’re ready to generate the code.
npm run build:protobuf
This command will generate two files: src/libs/protobuf.d.ts
and src/libs/protobuf.js
. Now, we’re ready to generate tiles.
The Mapbox Vector Tile format
Mapbox provides a very good guide on how data is laid out in the MVT container in their documentation. I highly recommend reading through it, but here are a few takeaways especially relevant to us:
- Feature property values and keys are deduplicated and referred to by index.
- Coordinates are encoded using “zigzag” encoding to save space while supporting negative coordinates. Starting from zero, 0, -1, 1, -2, 2, -3, 3…
- Drawing points, lines, and polygons differ from GeoJSON in a few major ways:
- Instead of absolute coordinates, drawing is done using a cursor and relative coordinates to the last operation.
- Instead of declaring whether a geometry is a point, line, or polygon, MVT uses commands to imperatively draw geometry.
- Coordinates are local to the tile, where (0,0) refers to the top-left corner of the tile.
Now that we’re ready to jump in to actually building up these tiles, let’s try some code.
import { vector_tile } from "../libs/protobuf"; // zigzag encoding const zz = (value: number) => (value << 1) ^ (value >> 31); const tile = vector_tile.Tile.create({ layers: [ { version: 2, // This is a constant name: "test", // The name of the layer extent: 256, // The extent of the coordinate system local to this tile. 256 means that this tile has 256×256=65536 pixels, from (0,0) to (256,256). features: [ { // id must be unique within the layer. id: 1, type: vector_tile.Tile.GeomType.POLYGON, geometry: [ // We start at (0,0) ((1 & 0x7) | (1 << 3)), // MoveTo (1) for 1 coordinate. zz(5), zz(5), // Move to (5,5) ((2 & 0x7) | (3 << 3)), // LineTo (2) for 3 coordinates. zz(1), zz(0), // Draw a line from (5,5) to (6,5) zz(0), zz(1), // Draw a line from (6,5) to (6,6) zz(-1), zz(0), // Draw a line from (6,6) to (5,6) 15, // Close Path (implicitly closes the line from (5,6) to (5,5)) ], tags: [ 0, // test-property-key-1 0, // value of key 1 1, // test-property-key-2 1, // value of key 2 and 3 2, // test-property-key-3 1, // value of key 2 and 3 ], } ], keys: [ "test-property-key-1", "test-property-key-2", "test-property-key-3" ], values: [ {stringValue: "value of key 1"}, {stringValue: "value of key 2 and 3"}, ], } ] });
I tried to annotate the code as much as possible. Can you figure out what shape this draws?
Now, to convert this to binary:
const buffer: Buffer = vector_tile.Tile.encode(tile).finish();
And that’s it!
Conclusion
I was initially pretty scared about creating vector tiles from scratch — I’ve found them pretty hard to work with in the past, leaning on tools like vt2geojson to first convert them to GeoJSON. I was pleasantly surprised to find out that it wasn’t as hard as I thought it was going to be. I still got stuck on a few parts — it took me a few trial and error runs to figure out that my absolute-to-relative math was off — but once everything was working, it was definitely worth it.
Let me know what you think.