Vector tiles — deep dive
On this page 5
VectorTileMapLayer is a GridLayer that fetches Mapbox Vector Tile (.pbf) tiles, decodes them with the in-house Pbf + VectorTile pipeline, and rasterises the result to a per-tile <canvas> via Canvas2D (default) or WebGL (opt-in).
Use it when you want vector-quality rendering from an MVT tileset (OpenMapTiles, Mapbox, MapLibre, Protomaps, your own) without pulling in a GL renderer. It shares the tile-scheduling machinery with TileLayer, so pan / zoom / tile pruning / abort / attribution all behave the same way.
Minimal usage
import { TsMap, vectorTileLayer } from 'ts-maps'
const map = new TsMap('map', { center: [51.5, -0.12], zoom: 6 })
vectorTileLayer({
url: 'https://tiles.example.com/{z}/{x}/{y}.pbf',
tileSize: 512,
layers: [
{
id: 'water',
type: 'fill',
sourceLayer: 'water',
paint: { 'fill-color': '#0ea5e9', 'fill-opacity': 0.5 },
},
{
id: 'roads',
type: 'line',
sourceLayer: 'transportation',
minzoom: 6,
paint: { 'line-color': '#6b7280', 'line-width': 1.2 },
},
],
}).addTo(map)
Query features under a pointer (point) or within a rectangle (bbox):
map.on('click', (e) => {
const hits = layer.queryRenderedFeatures({
point: [e.containerPoint.x, e.containerPoint.y],
layers: ['roads'],
})
console.log(hits.map(h => h.feature.properties))
})
// Rectangle select:
const inArea = layer.queryRenderedFeatures({ bbox: [[100, 100], [400, 400]] })
Architecture notes
- One canvas per tile. Each
createTilereturns a<canvas>whose size matchestileSize;GridLayerhandles layout, positioning, and pruning. - Canvas2D today, WebGL opt-in.
fill/line/circle/fill-extrusionpaint properties map directly onto 2D-context primitives (or GL programs whenrenderer: 'webgl'). Line dashes and paint opacity are supported; gradients, patterns, and pixel-perfect antialias live on the Canvas2D path. - Lazy feature decoding.
VectorTileFeature.loadGeometry()parses the geometry command stream on demand; property tables are built eagerly per layer, but a feature's values are resolved only when accessed. - Expression engine. Paint and layout properties accept the full style-spec expression DSL —
['interpolate', ...],['match', ...],['case', ...],['get', ...],['feature-state', ...]and friends. Unknown operators fall back to pass-through evaluation, not an error. - R-tree
queryRenderedFeatures. Each decoded tile builds a per-tile R-tree keyed on feature bboxes. Point and bbox queries bulk-load the tree lazily after decode, then refine R-tree candidates with a precise point-in-geometry pass (ray casting for polygons, segment-distance for lines, radius-check for points). - Abort on pan/zoom. Each in-flight tile gets an
AbortController;_removeTileandonRemovecancel pending fetches so tile churn doesn't leak network work. - Post-pass hooks. When the WebGL renderer is active,
_drawTileruns a terrain underlay (ifmap.setTerrain(...)is active) and iterates registered custom layers viamap._invokeCustomLayerRender(gl, proj)after the style-layer draw loop.
Tile URL templates
Templates use the same variables as TileLayer:
{z},{x},{y}— tile coordinates{s}— subdomain (fromoptions.subdomains, a string like'abc'or an array){r}— retina suffix (reserved)
Missing template variables throw eagerly so typos fail loudly.
Architecture inspired by mapbox-gl-js / maplibre-gl-js. Independent TypeScript implementation with no runtime dependencies.
Rendering resolution
Tiles are rasterised at the display's own pixel density. A canvas sized in CSS
pixels is stretched across twice as many device pixels on a retina screen, and
everything drawn into it — roads, buildings, labels alike — arrives upscaled
and soft. The backing store is sized by devicePixelRatio (capped at 2, since
3x costs nine times the fill rate for a difference nobody can see) and the 2D
context carries a matching transform, so every draw call still works in CSS
pixels and none of them needs to know.
Text is drawn through the canvas's own text engine rather than blitted from the
glyph atlas. The atlas holds a distance field rasterised at one size, so every
label was a resample of it — thresholded and softened, which is what made
labels look blurry next to the crisp vector lines beside them. fillText
renders at the device resolution with real hinting, and strokeText gives a
halo that follows the glyph outline instead of approximating it. The atlas
remains the measurement authority and the WebGL path's texture.
Labels
Symbol layers are placed every frame the camera moves, not only when it comes to rest, so a street name stays on its street through a zoom and a neighbourhood name stays over its neighbourhood — the way Apple Maps and Google Maps behave.
- Resolved once per tile. Text, font, size, colours and the label's box are evaluated when a tile arrives (layout at the tile's zoom, as the style spec does). A frame is then projection, collision and a sprite blit per label, typically well under 2 ms for a city view.
- Priority. Layers later in the style are placed first, and within a layer
a lower
symbol-sort-keywins, as in the style spec. The legacysymbol-prioritylayout property keeps its higher-wins meaning. - Stable. A label that was showing last frame is tried before an equal one that was not, so labels do not trade places as the camera moves, and a label that was not showing needs a few pixels of clear space before it takes a slot — so one on the edge of fitting does not blink through a slow zoom.
- Steady. Labels are projected with the camera's exact, unrounded maths and drawn on whole device pixels, so they hold still on the ground through a zoom rather than shaking by a pixel from frame to frame. The same place from two zoom levels' tiles is recognised as one label and keeps its fade.
- Faded. Labels fade in and out over 200 ms when they gain or lose their slot, instead of popping.
- Not repeated. The same street name is kept a label-width and a half, or
most of
symbol-spacing, from its last copy on screen, even across tiles.
text-max-width wraps long point labels onto balanced lines, text-transform
and text-padding are honoured, and text-opacity applies to the text.