ts-maps

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 createTile returns a <canvas> whose size matches tileSize; GridLayer handles layout, positioning, and pruning.
  • Canvas2D today, WebGL opt-in. fill / line / circle / fill-extrusion paint properties map directly onto 2D-context primitives (or GL programs when renderer: '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; _removeTile and onRemove cancel pending fetches so tile churn doesn't leak network work.
  • Post-pass hooks. When the WebGL renderer is active, _drawTile runs a terrain underlay (if map.setTerrain(...) is active) and iterates registered custom layers via map._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 (from options.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-key wins, as in the style spec. The legacy symbol-priority layout 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.