maplibre-properlabels
v1.0.0
Published
tiny maplibre plugin to place labels properly for tiled geometries
Readme
maplibre-proper-labels
Maplibre GL JS plugin for proper labelling of polygons that extend across tiles.

(red labels are the "proper" ones 😅)
Live example at
https://abelvm.github.io/maplibre-properlabels/example/
Why
Any tiled-sourced vector layer in MapLibre lacks proper labelling, as every geometry that extends through several tiles has several labels, one per geometry portion.
This just grin my gears

This is inspired by https://github.com/maplibre/maplibre-tile-spec/issues/710 and my stubbornness
How to use
Build
Just grab the files in the dist folder, or run npm run build to regenerate those files
Install
Install from npm (recommended):
npm install maplibre-properlabelsThen import in your project:
import ProperLabels from 'maplibre-properlabels';
// or, if using CommonJS:
// const ProperLabels = require('maplibre-properlabels').default;Use via CDN (jsDelivr / unpkg):
<!-- jsDelivr -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/maplibre-properlabels.js"></script>
<!-- or unpkg -->
<script src="https://unpkg.com/[email protected]/dist/maplibre-properlabels.js"></script>And then
const proper = new ProperLabels({
map: map,
source: 'demotiles',
sourceLayer: 'countries'
});When using the CDN bundle the plugin registers itself on maplibregl.VectorTileSource.prototype and can also be used like this:
const mysource = map.getSource('demotiles');
const proper = mysource.ProperLabels({
// no need to provide `map` or `source` as they are implicit in `mysource`
sourceLayer: 'countries'
});Use
Initialize the plugin once the map is ready. The constructor accepts an options object:
| name | type | description | optional | default |
|---|---|---|---|---|
| map | Maplibre Map instance | The map instance | required | — |
| source | string | Vector tile source id, or a VectorTileSource object | required | — |
| sourceLayer | string | The inner layer name inside the vector tiles to label | required | — |
| fid | string | Property name to promote as feature id (promoteId) | optional | id |
| tolerance | number | Simplify / polylabel precision (degrees) | optional | 0.00001 |
| cacheSize | number | Worker-side cache capacity (entries) | optional | 10000 |
| postDelay | number | Debounce delay (ms) before posting features to worker | optional | 100 |
Example (see example/index.html):
map.on('load', () => {
const proper = new ProperLabels({
map,
source: 'demotiles', // can also be a VectorTileSource object
sourceLayer: 'countries',
fid: 'fid', // optional, property used as promoted id
tolerance: 0.00001,
cacheSize: 10000
});
// The plugin creates a GeoJSON source named `${sourceId}-proper`.
// Use it when adding a label layer:
map.addLayer({
id: 'countries-labels-proper',
type: 'symbol',
source: 'demotiles-proper',
layout: {
'text-field': ['coalesce', ['get', 'name'], ['get', 'name_en'], ['get', 'NAME'], ''],
'text-size': 12
},
paint: { 'text-color': '#ff0000' }
});
});How does it work
I've spent several days trying to put a man in the middle of the lifecycle of the features bucket of MaplibreGL JS, to upstream this functionality, but regardless the approach... the rendered always picked the raw features instead of the processed ones, so, long story short, this is a plugin instead of a PR. And, as a plugin without access to internals, it's not as elegant as it could be. Meh.
And how does it work?
- On new data loading the plugin queries the vector-tile source for all loaded features using
map.querySourceFeatures(sourceId, { sourceLayer }). - Features are grouped by the promoted id so every logical feature (which may be split across tiles) is processed as a single group.
- The main thread encodes the groups into a compact binary transferable (Float32 coordinate buffer + key-indexed properties buffer) and posts it to a worker. An
ArrayBufferPoolis used to reduce allocations. - The worker decodes the binary payload, runs geometry processing (simplify, union/flatten/combine for multi-part groups, and a safe
polylabelfallback), and computes a short raw-group signature and geometry hashes to detect unchanged items. - The worker keeps a cache of processed features and emits incremental diffs (adds/updates/removes). Add/update feature lists are encoded as binary transferables and property diffs are compacted into a shared keys table + props buffer to minimize structured-clone cost.
- The main thread decodes the binary diffs, reconstructs a canonical
GeoJSONSourceDiffand applies it withsource.updateData(diff). A short handshake (diff_ack) lets the worker commit pending changes to its cache only after the main thread successfully applied the diff.
This design keeps the main thread lightweight by transferring buffers, applying incremental diffs, and avoiding expensive geometry work on the UI thread.
Local development
To run the example locally:
- Install dependencies
npm install- Start the dev server (Vite serves the example at
/example)
npm run dev
# open http://localhost:5173/example/Or build the package and open example/index.html after npm run build.
Performance & implementation notes
- The plugin offloads heavy geometry processing to a worker and uses compact binary transferables (Float32 coords + key-indexed properties) to minimize main-thread cost.
- Diffs between runs are encoded as binary transfer messages so
updateDatacan be applied with minimal structured-clone overhead. - Geometry hashing uses a lightweight Float32-based hash with a small deep-equality fallback to avoid unnecessary recomputation.
- For debugging, enable tile boundaries with
map.showTileBoundaries = trueand use the example legend to correlate labels and clipped geometry.
