21 KiB
Writing vector tiles
Writing vector tiles start with creating a tile_builder
. This builder will
then be used to add layers and features in those layers. Once all this is done,
you call serialize()
to actually build the vector tile from the data you
provided to the builders:
#include <vtzero/builder.hpp> // always needed when writing vector tiles
vtzero::tile_builder tbuilder;
// add lots of data to builder...
std::string buffer = tbuilder.serialize();
You can also serialize the data into an existing buffer instead:
std::string buffer; // got buffer from somewhere
tbuilder.serialize(buffer);
Adding layers to tiles
Once you have a tile builder, you'll first need some layers:
vtzero::tile_builder tbuilder;
vtzero::layer_builder layer_pois{tbuilder, "pois", 2, 4096};
vtzero::layer_builder layer_roads{tbuilder, "roads"};
vtzero::layer_builder layer_forests{tbuilder, "forests"};
Here three layers called "pois", "roads", and "forests" are added. The first one explicitly specifies the vector tile version used and the extent. The values specified here are the default, so all layers in this example will have a version of 2 and an extent of 4096.
If you have read a layer from an existing vector tile and want to copy over some of the data, you can use this layer to initialize the new layer in the new vector tile with the name, version and extent from the existing layer like this:
vtzero::layer some_layer = ...;
vtzero::layer_builder layer_pois{tbuilder, some_layer};
// same as...
vtzero::layer_builder layer_pois{tbuilder, some_layer.name(),
some_layer.version(),
some_layer.extent()};
If you want to copy over an existing layer completely, you can use the
add_existing_layer()
function instead:
vtzero::layer some_layer = ...;
vtzero::tile_builder tbuilder;
tbuilder.add_existing_layer(some_layer);
Or, if you have the encoded layer data available in a data_view
this also
works:
vtzero::data_view layer_data = ...;
vtzero::tile_builder tbuilder;
tbuilder.add_existing_layer(layer_data);
Note that this call will only store a reference to the data to be added in the
tile builder. The data will only be copied when the final serialize()
is
called, so the input data must still be available then!
You can mix any of the ways of adding a layer to the tile mentioned above. The
layers will be added to the tile in the order you add them to the
tile_builder
.
The tile builder is smart enough to not add empty layers, so you can start
out with all the layers you might need and if some of them stay empty, they
will not be added to the tile when serialize()
is called.
Adding features to layers
Once we have one or more layer_builder
s instantiated, we can add features
to them. This is done through the following feature builder classes:
point_feature_builder
to add a feature with a (multi)point geometry,linestring_feature_builder
to add a feature with a (multi)linestring geometry,polygon_feature_builder
to add a feature with a (multi)polygon geometry, orgeometry_feature_builder
to add a feature with an existing geometry you got from reading a vector tile.
In all cases you need to instantiate the feature builder class, optionally
add the feature ID using the set_id()
method, add the geometry and then
add all the properties of this feature. You have to keep to this order!
...
vtzero::layer_builder lbuilder{...};
{
vtzero::point_feature_builder fbuilder{lbuilder};
// optionally set the ID
fbuilder.set_id(23);
// add the geometry (exact calls are different for different feature builders)
fbuilder.add_point(99, 33);
// add the properties
fbuilder.add_property("amenity", "restaurant");
// call commit() when you are done
fbuilder.commit()
}
You have to call commit()
on the feature builder object after you set all the
data to actually add it to the layer. If you don't do this, the feature will
not be added to the layer! This can be useful, for instance, if you detect that
you have an invalid geometry while you are adding the geometry to the feature
builder. In that case you can call rollback()
explicitly or just let the
feature builder go out of scope and it will do the rollback automatically.
Only the first call to commit()
or rollback()
will take effect, any further
calls to these functions on the same feature builder object are ignored.
Adding a geometry to the feature
There are different ways of adding the geometry to the feature, depending on the geometry type.
Adding a point geometry
Simply call add_point()
to set the point geometry. There are three different
overloads for this function. One takes a vtzero::point
, one takes two
uint32_t
s with the x and y coordinates and one takes any type T
that can
be converted to a vtzero::point
using the create_vtzero_point
function.
This templated function works on any type that has x
and y
members and
you can create your own overload of this function. See the
[advanced.md](advanced topics documentation).
Adding a multipoint geometry
Call add_points()
with the number of points in the geometry as the only argument.
After that call set_point()
for each of those points. set_point()
has
multiple overloads just like the add_point()
method described above.
There is also the add_points_from_container()
function which copies the
point from any container type supporting the size()
function and which
iterator yields a vtzero::point
or something convertible to it.
Adding a linestring geometry
Call add_linestring()
with the number of points in the linestring as only
argument. After that call set_point()
for each of those points. set_point()
has multiple overloads just like the add_point()
method described above.
...
vtzero::layer_builder lbuilder{...};
try {
vtzero::linestring_feature_builder fbuilder{lbuilder};
// optionally set the ID
fbuilder.set_id(23);
// add the geometry
fbuilder.add_linestring(2);
fbuilder.set_point(1, 2);
fbuilder.set_point(3, 4);
// add the properties
fbuilder.add_property("highway", "primary");
fbuilder.add_property("maxspeed", 80);
// call commit() when you are done
fbuilder.commit()
} catch (const vtzero::geometry_exception& e) {
// if we are here, something was wrong with the geometry.
}
Note that we have wrapped the feature builder in a try-catch-block here. This will ignore all geometry errors (which can happen if two consective points are the same creating a zero-length segment).
There are two other versions of the add_linestring()
function. They take two
iterators defining a range to get the points from. Dereferencing those
iterators must yield a vtzero::point
or something convertible to it. One of
these functions takes a third argument, the number of points the iterator will
yield. If this is not available std::distance(begin, end)
is called which
internally by the add_linestring()
function which might be slow depending on
your iterator type.
Adding a multilinestring geometry
Adding a multilinestring works just like adding a linestring, just do the
calls to add_linestring()
etc. repeatedly for each of the linestrings.
Adding a polygon geometry
A polygon consists of one outer ring and zero or more inner rings. You have to first add the outer ring and then the inner rings, if any.
Call add_ring()
with the number of points in the ring as only argument. After
that call set_point()
for each of those points. set_point()
has multiple
overloads just like the add_point()
method described above. The minimum
number of points is 4 and the last point must be the same as the first point
(or call close_ring()
instead of the last set_point()
).
...
vtzero::layer_builder lbuilder{...};
try {
vtzero::polygon_feature_builder fbuilder{lbuilder};
// optionally set the ID
fbuilder.set_id(23);
// add the geometry
fbuilder.add_ring(5);
fbuilder.set_point(1, 1);
fbuilder.set_point(1, 2);
fbuilder.set_point(2, 2);
fbuilder.set_point(2, 1);
fbuilder.set_point(1, 1); // or call fbuilder.close_ring() instead
// add the properties
fbuilder.add_property("landuse", "forest");
// call commit() when you are done
fbuilder.commit()
} catch (const vtzero::geometry_exception& e) {
// if we are here, something was wrong with the geometry.
}
Note that we have wrapped the feature builder in a try-catch-block here. This will ignore all geometry errors (which can happen if two consective points are the same creating a zero-length segment or if the last point is not the same as the first point).
There are two other versions of the add_ring()
function. They take two
iterators defining a range to get the points from. Dereferencing those
iterators must yield a vtzero::point
or something convertible to it. One of
these functions takes a third argument, the number of points the iterator will
yield. If this is not available std::distance(begin, end)
is called which
internally by the add_ring()
function which might be slow depending on your
iterator type.
Adding a multipolygon geometry
Adding a multipolygon works just like adding a polygon, just do the calls to
add_ring()
etc. repeatedly for each of the rings. Make sure to always first
add an outer ring, then the inner rings in this outer ring, then the next
outer ring and so on.
Adding an existing geometry
The geometry_feature_builder
class is used to add geometries you got from
reading a vector tile. This is useful when you want to copy over a geometry
from a feature without decoding it.
auto geom = ... // get geometry from a feature you are reading
...
vtzero::tile_builder tb;
vtzero::layer_builder lb{tb};
vtzero::geometry_feature_builder fb{lb};
fb.set_id(123); // optionally set ID
fb.add_geometry(geom) // add geometry
fb.add_property("foo", "bar"); // add properties
fb.commit();
...
Adding properties to the feature
A feature can have any number of properties. They are added with the
add_property()
method called on the feature builder. There are two different
ways of doing this. The simple approach which does all the work for you and
the advanced approach which can be more efficient, but you have to to some
more work. It is recommended that you start out with the simple approach and
only switch to the advanced approach once you have a working program and want
to get the last bit of performance out of it.
The difference stems from the way properties are encoded in vector tiles. While
properties "belong" to features, they are really stored in two tables (for the
keys and values) in the layer. The individual feature only references the
entries in those tables by index. This make the encoded tile smaller, but it
means somebody has to manage those tables. In the simple approach this is done
behind the scenes by the layer_builder
object, in the advanced approach you
handle that yourself.
Do not mix the simple and the advanced approach unless you know what you are doing.
The simple approach to adding properties
For the simple approach call add_property()
with two arguments. The first is
the key, it must have some kind of string type (std::string
, const char*
,
vtzero::data_view
, anything really that converts to a data_view
). The
second argument is the value, for which most basic C++ types are allowed
(string types, integer types, double, ...). See the API documentation for the
constructors of the encoded_property_value
class for a list.
vtzero::layer_builder lb{...};
vtzero::linestring_feature_builder fb{lb};
...
fb.add_property("waterway", "stream"); // string value
fb.add_property("name", "Little Creek");
fb.add_property("width", 1.5); // double value
...
Sometimes you need to specify exactly which type should be used in the
encoding. The encoded_property_value
constructor can take special types for
that like in the following example, where you force the sint
encoding:
fb.add_property("layer", vtzero::sint_value_type(2));
You can also call add_property()
with a single vtzero::property
argument
(which is handy if you are copying this property over from a tile you are
reading):
while (auto property = feature.next_property()) {
if (property.key() == "name") {
feature_builder.add_property(property);
}
}
The advanced approach to adding properties
In the advanced approach you have to do the indexing yourself. Here is a very basic example:
vtzero::tile_builder tbuilder;
vtzero::layer_builder lbuilder{tbuilder, "test"};
const vtzero::index_value highway = lbuilder.add_key("highway");
const vtzero::index_value primary = lbuilder.add_value("primary");
...
vtzero::point_feature_builder fbuilder{lbuilder};
...
fbuilder.add_property(highway, primary);
...
The methods add_key()
and add_value()
on the layer builder are used to add
keys and values to the tables in the layer. They both return the index (of type
vtzero::index_value
) of those keys or values in the tables. You store
those index values somewhere (in this case in the highway
and primary
variables) and use them when calling add_property()
on the feature builder.
In some cases you only have a few property keys and know them beforehand, then storing the key indexes in individual variables might work. But for values this usually doesn't make much sense, and if all your keys and values are only known at runtime, it doesn't work either. For this you need some kind of index data structure mapping from keys/values to index values. You can implement this yourself, but it is easier to use some classes provided by vtzero. Then the code looks like this:
#include <vtzero/index.hpp> // use this include to get the index classes
...
vtzero::layer_builder lb{...};
vtzero::key_index<std::map> key_index{lb};
vtzero::value_index_internal<std::unordered_map> value_index{lb};
...
vtzero::point_feature_builder fb{lb};
...
fb.add_property(key_index("highway"), value_index("primary"));
...
In this example the key_index
template class is used for keys, it uses
std::map
internally as can be seen by its template argument. The
value_index_internal
template class is used for values, it uses
std::unordered_map
internally in this example. Whether you specify std::map
or std::unordered_map
or something else (that needs to be compatible to those
classes) is up to you. Benchmark your use case and decide then.
Keys are always strings, so they are easy to handle. For keys there is only the
single key_index
in vtzero.
For values this is more difficult. Basically there are two choices:
- Encode the value according to the vector tile encoding rules which results
in a string and store this in the index. This is what the
value_index_internal
class does. - Store the un-encoded value in the index. The index lookup will be faster,
but you need a different index type for each value type. This is what the
value_index
classes do.
The value_index
template classes need three template arguments: The type
used internally to encode the value, the type used externally, and the map
type.
In this example the user program has the values as int
, the index will store
them in a std::map<int>
. The integer value is then encoded in an sint
int the vector tile:
vtzero::value_index<vtzero::sint_value:type, int, std::map> index;
Sometimes these generic indexes based on std::map
or std::unordered_map
are inefficient, that's why there are specialized indexes for special cases:
- The
value_index_bool
class can only index boolean values. - The
value_index_small_uint
class can only index small unsigned integer values (up touint16_t
). It uses a vector internally, so if all your numbers are small and densely packed, this is very efficient. This is especially useful forenum
types.
The add_property()
function.
The last chapters already talked about the add_property()
function of the
feature_builder
class. But because it is a bit difficult to see all the
different ways add_property()
can be called, here is some more information.
The add_property()
function is called with either two parameters for the
key and value or with one parameter that combines the key and value.
If it is called with an index_value
for the key or value, that index value is
stored directly into the feature. If it is called with an index_value_pair
,
the index values in the index_value_pair
are stored directly in the feature.
If it is called with something that is not an index_value
or
index_value_pair
, the function will interpret the data as keys or values.
It will add those keys and values to the layer (if they are not already there),
find the corresponding index values and store them in the feature.
You can mix index-use with non-index use. For instance
index_value key_maxspeed = lbuilder.add_key("maxspeed");
...
fbuilder.add_property(key_maxspeed, 30);
In this case the key ("maxspeed") was added to the layer once and its index
value (key_maxspeed
) can later be reused. The value (30), on the other hand,
is only added to the layer in the add_property()
call.
So for keys, you can have as argument:
- An
index_value
. - A
data_view
or something that converts to it like aconst char*
orstd::string
.
For values, you can have as argument:
- An
index_value
. - A
property_value
. - An
encoded_property_value
or anything that converts to it.
For combined keys and values, you can have as argument:
- An
index_value_pair
. - A
property
.
Deriving from layer_builder
and feature_builder
The vtzero::layer_builder
and vtzero::feature_builder
classes have been
designed in a way that they can be derived from easily. This allows you to
encapsulate part of your vector tile writing code if some aspects of your
layers/features are always the same, such as the layer name and the names
and types of properties.
Say you want to write a layer named "restaurants" with point geometries.
Each feature should have a name and a 5-star-rating. First you create a
class derived from the layer_builder
with all the indexes you want to use.
For the keys you don't need indexes in this case, because there are only
two keys for which we can easily store the index values in the layer.
class restaurant_layer_builder : public vtzero::layer_builder {
public:
// The index we'll use for the "name" property values
vtzero::value_index<vtzero::string_value_type, std::string, std::unordered_map> string_index;
// The index we'll use for the "stars" property values
vtzero::value_index_small_uint stars_index;
// The index value of the "name" key
vtzero::index_value key_name;
// The index value of the "stars" key
vtzero::index_value key_stars;
restaurant_layer_builder(vtzero::tile_builder& tile) :
layer_builder(tile, "restaurants"), // the name of the layer
string_index(*this),
stars_index(*this),
key_name(add_key_without_dup_check("name")),
key_stars(add_key_without_dup_check("stars")) {
}
};
The we'll add a class derived from feature_builder
to help with adding
features:
class restaurant_feature_builder : public vtzero::feature_builder {
restaurant_layer_builder& m_layer;
public:
restaurant_feature_builder(restaurant_layer_builder& layer, uint64_t id) :
vtzero::point_feature_builder(layer), // always a point geometry
m_layer(layer) {
set_id(id); // we always have an ID in this case
}
void add_location(mylocation& loc) { // restaurant location is stored in your own type
add_point(loc.lon(), loc.lat());
}
void set_name(const std::string& name) {
add_property(m_layer.key_name,
m_layer.string_index(vtzero::encoded_property_value{name}));
}
void set_stars(stars s) { // your own "stars" type
vtzero::encoded_property_value svalue{ s.num_stars() }; // convert stars type to small integer
add_property(m_layer.key_stars,
m_layer.stars_index(svalue));
}
};
This example only shows a general pattern you can follow to derive from the
layer_builder
and feature_builder
classes. In some cases this makes more
sense then in others. The derived classes make it easy for you to mix your
own functions (for instance when you need to convert from your own types to
vtzero types like with the mylocation
and stars
types above) or just use
the functions in the base classes.
Using a custom buffer type
Usually std::string
is used as a buffer type:
std::string buffer;
tbuilder.serialize(buffer);
But you can also use other buffer types supported by Protozero, like
std::vector<char>
:
#include <protozero/buffer_vector.hpp>
std::vector<char> buffer;
tbuilder.serialize(buffer);
Or you can use your own buffer types and write special adaptors for it. See the Protozero documentation for details.
Note that while in theory this allows you to also use fixed-sized buffers
through the protozero::fixed_sized_buffer_adaptor
class, vtzero will still
use std::string
for additional buffers internally.