Vector tiles
GET|POST /{index}/_mvt/{field}/{z}/{x}/{y} returns a Mapbox Vector Tile of whatever the query matched.
The response is not JSON describing geometry. It is the tile — bytes that MapLibre, Mapbox GL and OpenLayers already know how to draw:
map.addSource('fleet', {
type: 'vector',
tiles: ['https://cluster:9200/fleet/_mvt/location/{z}/{x}/{y}']
});
map.addLayer({ id: 'vehicles', type: 'circle', source: 'fleet', 'source-layer': 'hits' });
Why the cluster draws it
The usual arrangement puts a service in between: it queries the cluster, receives JSON, projects it, encodes a tile, and caches the result. That service has to be deployed, scaled, and kept in step with your mapping. Every map interaction becomes two network hops and one full serialization of data that is about to be thrown away — a hundred thousand points rendered as JSON so that a few kilobytes of tile can be produced from them.
Drawing the tile where the data already is removes all of that.
The layers
A tile carries up to four kinds of layer, and a map style chooses between them.
| Layer | Contents |
|---|---|
hits | One point feature per matched document, with _id and any fields you asked for. |
aggs | A grid of counts, one polygon per cell, tagged _count. This is what a map draws when zoomed out, where individual points would be a smear. |
meta | One polygon covering the tile, carrying hits.total.value, hits.rendered, aggs.rendered, took and timed_out. |
| per aggregation | One layer per geometry-producing aggregation, named after it. |
The meta layer is enough for a client to say "showing 5,000 of 4.3 million" without a second request.
A geo_line becomes a layer of lines and a convex_hull a layer of polygons. Under a terms parent each bucket becomes a feature tagged with its key, so "a track per vehicle" is one request:
POST /fleet/_mvt/location/9/152/196
{
"size": 0,
"grid_precision": 0,
"aggs": {
"by_vehicle": {
"terms": { "field": "vehicle_id", "size": 200 },
"aggs": {
"track": { "geo_line": { "field": "location", "sort": "@timestamp", "size": 500 } }
}
}
}
}
Keeping the layers apart is what lets one tile serve every zoom level: the style switches between them on its own, with no round trip and no second endpoint.
Parameters
All optional, in the request body.
| Parameter | Default | Description |
|---|---|---|
query | match all | Restricts what the tile shows. |
size | 10000 | How many documents come back as features. 0 omits the hits layer. |
fields | none | Document fields to attach to each feature as attributes. |
grid_precision | 8 | Extra zoom levels for the summary grid; 8 is a 256×256 grid. 0 omits the aggs layer. |
extent | 4096 | The tile's internal coordinate resolution. |
buffer | 5 | How far outside the tile geometry is still gathered, in tile coordinates. |
track_total_hits | true | Whether meta reports an exact total. |
graph | none | A graph to draw as edges. See below. |
The tile's own envelope is added to your query as a bounding box, so a request only ever reads the documents it could possibly draw. buffer widens that envelope: a feature just outside the edge still affects what is drawn inside it, and without it tiles show seams.
Drawing a graph
Lucenia indexes graphs, and a graph with places attached is a map: road and rail networks, pipelines and cable routes, supply lines, payment corridors, patrol handovers. Add a graph block and the edges come back as their own layer, alongside everything else the tile carries.
POST /roads/_mvt/shape/9/152/196
{
"size": 0,
"grid_precision": 0,
"graph": {
"field": "road",
"geometry_field": "shape"
}
}
Each feature in the graph layer is a line carrying _source and _target — the identifiers of the nodes it joins — so a click on a drawn edge can be turned straight back into a query.
Where the positions come from
An edge joins two nodes, and a node identifier is a number, not a place. There are two ways to find the two positions, and which applies is a property of your data.
The edge already knows. Roads, rail, pipelines and cables carry the segment's own shape, because the segment is not a straight line between its endpoints. Name that field in geometry_field and nothing has to be looked up — and because the geometry is indexed, the tile's own footprint narrows the search before anything is read. This is the path that scales to a continental network.
A node index knows. Otherwise, give a node_index holding one document per node with its identifier and its location. This is the right model when an edge is genuinely a straight line between two places, or when nodes move.
POST /flights/_mvt/location/4/3/6
{
"size": 0,
"grid_precision": 0,
"graph": {
"field": "route",
"node_index": "airports",
"node_field": "code",
"node_location": "at"
}
}
There is deliberately no third option — copying node positions onto every edge document. It is the fastest thing to draw and the worst thing to own: move one node and every edge that touches it has to be rewritten.
Scoping by a traversal
Give a source and each edge additionally carries reachable, saying whether it lies inside the subgraph reachable from that node. One style can then dim the surrounding network and light up the part that answers the question, from a single request.
{
"graph": {
"field": "road",
"geometry_field": "shape",
"source": 84021,
"hops": 4
}
}
hops is a number, or the string "unbounded" to walk until the component runs out. The default is 2, which is deliberately shallow: each further hop can multiply the frontier by the average degree, so the right depth is a property of your question rather than something software should assume. "hops": 6 is a reasonable supply-chain question; "hops": "unbounded" is a reasonable connected-component one.
Keeping a dense network legible
At low zoom a whole network is a grey smear, and the answer is to draw less of it rather than to bundle it into something no longer true. Name a numeric field in weight_field and edges carry it as an attribute, so a style can scale line width by importance; add min_weight and the unimportant ones are not sent at all.
Graph parameters
| Parameter | Default | Description |
|---|---|---|
field | required | The graph_edge field holding the edges. |
index | the tiled index | Where the edges live, if not the index being tiled. |
geometry_field | none | The field holding each edge's own shape. |
node_index | none | An index of node documents to resolve endpoints against. |
node_field | none | The field in node_index holding each node's identifier. |
node_location | none | The field in node_index holding each node's position. |
source | none | A node to traverse from; marks each edge reachable or not. |
hops | 2 | How far the traversal walks, or "unbounded". |
max_nodes | 65536 | The largest node set a traversal will gather. |
size | 5000 | The most edges a tile will draw. Maximum 50000. |
weight_field | none | A numeric field ranking edges by importance. |
min_weight | none | The least important edge worth drawing. |
Either geometry_field, or both node_index and node_location, is required. Without one of them there is nowhere to get positions from, and the request is refused rather than returning an empty tile.
One limit worth knowing up front
A graph_edge field is traversed, not searched, so there is no way to ask the index for "the edges touching these nodes". When positions come from a node index, an edge's whereabouts are unknown until it has been read, and the layer draws up to size of whatever your query matched.
That is right for a graph that fits in that budget, and the wrong tool for a continental network — give those edges a geometry_field and the tile's footprint does the narrowing first.
The meta layer reports graph.rendered, and graph.truncated when a traversal hit max_nodes before finishing. A map that has only seen part of a neighbourhood should say so.
What is left to the aggregations
This endpoint does not simplify, cluster or decimate. It projects and encodes.
That is deliberate. A track drawn on a tile is already the right number of vertices for the zoom because geo_line was asked for a vertex budget; a coverage polygon is already the right shape because convex_hull computed it. Deciding again here would mean this code needed to understand what every aggregation meant.