Maplibre GL JS plugin for proper labelling of polygons that extend across tiles.
(red labels are the "proper" ones 😅)
https://abelvm.github.io/maplibre-properlabels/example/
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 is inspired by maplibre/maplibre-tile-spec#710 and my stubbornness
Just grab the files in the dist folder, or run npm run build to regenerate those files
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/maplibre-properlabels@1.0.0/dist/maplibre-properlabels.js"></script>
<!-- or unpkg -->
<script src="https://unpkg.com/maplibre-properlabels@1.0.0/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'
});The returned helper is cached on the source. Repeated mysource.ProperLabels(...) calls with the same source and equivalent options return the same instance. If you pass different options, the previous instance is disposed and a new one is created.
The returned GeoJSON source also exposes a dispose() helper that cleans up the internal worker pipeline, removes the sourcedata listener, cancels pending tile processing, clears internal caches, and by default removes the generated auxiliary GeoJSON source from the map.
The plugin creates worker pools, subscribes to sourcedata events, and keeps internal tile/label caches while active. Call dispose() when you remove the proper-labels source, switch styles, or destroy the map instance to avoid worker leakage, stale listeners, and stale cached state.
dispose() currently performs the following cleanup:
- shuts down tile and gather worker pools
- cancels pending
postDelaytile drain scheduling - clears the tile and label caches
- removes the
sourcedataevent listener - removes the generated
${sourceId}-properGeoJSON source unlesskeepSource: true
Use keepSource: true when you want to preserve the generated ${sourceId}-proper GeoJSON source after disposal:
proper.dispose();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 maplibregl.VectorTileSource instance |
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 |
| units | string | Units for area calculations (meters or m) |
optional | meters |
| cacheSize | number | Worker-side cache capacity (entries) | optional | 5000 |
| postDelay | number | Debounce delay in ms for grouped sourcedata events |
optional | 0 |
| debugLevel | number | Debug verbosity level for PowerLogger output in the worker pipeline (0..3). | optional | 0 |
| keepSource | boolean | Preserve the generated auxiliary GeoJSON source when disposing | optional | false |
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,
units: 'meters',
cacheSize: 5000,
postDelay: 50,
debugLevel: 2,
keepSource: false
});
// 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' }
});
});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 transferable payload and posts it to a worker.
- The worker decodes the payload, runs geometry processing (simplify, union/flatten/combine for multi-part groups, and a safe
polylabelfallback), and computes a short raw-group signature to detect unchanged items. - The worker keeps a cache of processed features and emits incremental diffs (adds/updates/removes) back to the main thread.
- The main thread reconstructs a canonical
GeoJSONSourceDiffand applies it withsource.updateData(diff).
This design keeps the main thread lightweight by transferring buffers, applying incremental diffs, and avoiding expensive geometry work on the UI thread.
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.
- 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.

