@protomaze/protomaps-ria
v0.4.2
Published
Protomaps (PMTiles + MapLibre style JSON) vector basemap layer for LuciadRIA
Maintainers
Readme
@protomaze/protomaps-ria
A vector basemap layer for LuciadRIA: renders PMTiles archives of Mapbox Vector Tiles with a MapLibre style JSON, without MapLibre or Mapbox dependencies.
Usage
import { ProtomapsLayer } from '@protomaze/protomaps-ria';
const layer = new ProtomapsLayer('https://example.com/germany.pmtiles', {
id: 'basemap',
label: 'Basemap',
locale: 'de', // prefer `name:de` in labels
renderMode: 'raster', // default; 'vector' draws fills and lines as features too
});
layer.setStyle('resources/protomaps/light.json');
map.layerTree.addChild(layer);
// later
layer.setStyle('resources/protomaps/dark.json'); // styles are cached, switching back is instant
layer.labelsOnly = true; // only labels, shields, icons and one-way arrows, e.g. on top of satellite imagery
// Options htmlLabels (default true) and htmlShields (default true) draw place names and road shields as
// RIA HTML labels, crisper and on top of every layer; htmlAddressLabels (default false) does so for house
// numbers, and roundedShields (default true) rounds the corners of HTML shields like the sprite icons.
// Options symbolSpacing (default: the style's symbol-spacing, 250) sets how often a road or river
// repeats its name, and shieldOnlyZoom (default 14) the zoom below which a road with a shield
// shows only the shield; see Line labels below.
layer.flags = { mode: 'flag-first', shape: 'round', size: 20 }; // flags on country labels; see below
layer.dispose(); // when the layer is removed for good: stops the decoding workersPeer dependencies: @luciad/ria (2025.0 or newer) and pmtiles.
Line labels
Roads and rivers carry their name every symbol-spacing pixels (MapLibre's default is 250) along
the part of them the map shows, instead of once in the middle of the feature. The middle is a poor
place for it: vector tiles clip a road at the tile edge and the archive stops at zoom 15, so at
zoom 18 one piece of road spans some 4 000 pixels and its middle is usually off screen while the
road fills the view. Repeating the name also settles the question a single label leaves open on a
long river, which is whether the line still is the one that was named.
Road shields repeat on the same grid, one spacing out of step with the names, and a road that has
both takes every other place on it for each, so the road reads name, shield, name, shield with one
symbol per spacing. The two are separate style layers that never see each other; they interleave
because both measure the grid on the line as it is seen. A
shield outranks the street names it competes with, whatever the order of the two layers in the
style, so where only one symbol fits it is the shield. Below shieldOnlyZoom (14 by default, 0
to switch it off) a road that carries a shield drops its name altogether: a motorway number reads
on a map of a whole region where its name would only take the room of four other labels.
const layer = new ProtomapsLayer(url, { symbolSpacing: 400, shieldOnlyZoom: 13 });Nothing here depends on where the map is looking, so panning neither re-places nor repaints a label: the repetitions sit at fixed distances along the line and stay anchored on the map, exactly as a single label did.
Flags
Place labels can carry a flag. Flags are off by default; the flags option of the layer (and the
flags property, which takes effect at once) switches them on:
const layer = new ProtomapsLayer(url, {
flags: {
mode: 'flag-first', // 'name' (default, no flags) | 'flag' | 'both' | 'flag-first'
shape: 'rectangle', // 'rectangle' (the flag's own proportions) | 'square' | 'round'
size: 16, // height of the flag in CSS pixels, border excluded
borderColor: 'rgba(0,0,0,0.25)', // null for no border
borderWidth: 1,
cornerRadius: 0, // rectangle and square only; round flags are circles
gap: 4, // space between the flag and the name; defaults to a quarter of the size
kinds: ['country', 'region', 'locality'], // which places get one; defaults to ['country']
wikidata: true, // flags of states and cities, from Wikidata and Wikimedia Commons
baseUrl: 'https://www.protomaze.com/resources/img/flags', // <baseUrl>/4x3/de.svg, <baseUrl>/1x1/de.svg
},
});modedecides what a label shows: itsname(the default, as without flags), theflagalone,both, orflag-first— the flag and the name where both find room, the flag alone where they do not. The flag goes to the left of the name and the two are decluttered as one label, soflag-firstis the mode that fits the most places on a crowded map.shapepicks the image as well as the outline:rectanglekeeps each flag's own proportions (between 1:1 and 2:1) at the given height, whilesquareandroundcrop it to a chip. Countries use the 4:3 image for a rectangle and the 1:1 image for the other two.- Flags that have not arrived yet are left out and the label is painted again once they have, so a label never waits for its image; a flag that fails to load never appears.
url: (code, ratio) => stringreplacesbaseUrlwhen the flags are laid out differently.countryCode: (properties) => string | null | undefinedgives the flag of features the built-in rules do not cover: a code for a flag, null for none, undefined to leave the feature to the rules (an ISO code property, else the Wikidata item of acountryplace, else its English name — Protomaps tiles carry no ISO code, sowikidatais the usual source; the table issrc/style/CountryCodeTable.ts, regenerated withnode dev/build_country_codes.js).
Flags of states, provinces and cities
Only countries have an ISO code and a flag-icons image. Everything else — region (states and
provinces) and locality (towns and cities) — is flagged through Wikidata: every place in the
Protomaps tiles carries its Wikidata item (wikidata, Q64 for Berlin), Wikidata's flag image
property (P41) names the file on Wikimedia Commons, and the image comes from Commons. Some 62 000
items have a flag, 8 800 of them places with more than 50 000 inhabitants, which is far too many
to ship as a table, so wikidata: true looks them up while the map paints:
- The items on screen are asked for in one batched SPARQL query per 100 (
query.wikidata.org, which allows cross-origin requests), and the answers are kept inlocalStoragefor 30 days. Measured on Germany at zoom 6 with 640 labels: 5 queries and 190 flag images (0.3–3 KB each) on the first visit, nothing extra while panning within loaded tiles, and after a reload 2 queries for the few new items with every image served from the browser cache.wikidata: {...}sets the endpoint, batch size, batching delay and cache; seeWikidataFlagOptions. - Images are the Commons thumbnails, addressed directly on
upload.wikimedia.org(the package works the path out from the MD5 of the file name, as MediaWiki does). The obviousSpecial:FilePathURL redirects there but its redirect carries no CORS header, which would taint the canvas of a rasterized label, so it is not used; Commons only renders a few thumbnail widths, and a raster flag too small for one falls back to the original file. - Wikidata has no flag for many places (London has none), and a city's flag is often a coat of
arms, which is a busy thing at 16 px.
kindstherefore takes the places that should have one, andsizeis worth raising when cities are included. - Commons files are free but not all public domain: some are CC BY-SA and want attribution. An app that shows them should credit Wikimedia Commons somewhere, as it credits the tiles.
- A production map that cannot depend on Wikidata being up can mirror the images (the
wikidataoption then stays off andcountryCode/urlpoint at the mirror), which is the same trade-off as for the country flags below.
The flags are the flag-icons SVGs (MIT), by default the
copy on protomaze.com. htmlLabels: false needs CORS: rasterized labels draw the flag into a
canvas, which LuciadRIA then uploads as a texture, and a canvas that a cross-origin image was
drawn into is tainted and cannot be uploaded. Those images are therefore requested with CORS and
are dropped unless the server sends Access-Control-Allow-Origin; protomaze.com does not today,
so with htmlLabels: false either serve the flags from the app's own origin (or a CORS-enabled
host) through baseUrl, or allow the origin on the bucket. The default HTML labels need none of
this: the flag is an <img> in the label.
The package ships no flag images. A full set is some 500 SVGs and a few megabytes, which would
have to be copied into every app's assets (a bundler cannot resolve new URL('./flags/' + code +
'.svg', import.meta.url)), and flags change more often than this code does; a URL keeps them one
deployment away and lets the browser cache them. To self-host, copy the flags/ folder of the
flag-icons package into the app's static assets and point baseUrl at it.
Publishing
Releases go out via .github/workflows/publish.yml using npm's
Trusted Publishing: no npm token lives anywhere, in CI or on any laptop. GitHub Actions proves
its identity to npm with a short-lived OIDC credential scoped to this exact repo and workflow file.
The workflow type-checks and compiles the sources and publishes build/, which is the package.
One-time setup (needs the npm account owner, see Trusted Publishers):
- On npmjs.com, under the package's settings, add a Trusted Publisher: GitHub Actions, repository
protomaze/protomaps-ria, workflow file.github/workflows/publish.yml. - Add repository secrets
LUCIAD_NPM_USERNAMEandLUCIAD_NPM_PASSWORDfor the private registry that serves@luciad/ria. It is a licensed package and a dev dependency here, because the sources are type-checked against it, and that registry has no OIDC; a read-only account is enough. The npm publish itself still uses no credentials.
After that, a release is:
npm version patch # or minor / major - bumps package.json and tags it
git push --follow-tagsThe tag push triggers the workflow. workflow_dispatch is also enabled, so a release can be re-run
by hand from the Actions tab.
Publishing from a laptop is not the path: the npm account has two-factor authentication, so a local
npm publish asks for a one-time code that a security key cannot produce, and the access tokens
that used to bypass this are being restricted by npm. npm run deploy and npm run pub remain for
emergencies and for a local Verdaccio on port 4873 respectively.
How it works
The sources are grouped by concern: src/layer (the LuciadRIA layer, painter, store and raster
model), src/tiles (MVT decoding, style matching and rasterization, run in the worker),
src/style (MapLibre style JSON and expressions) and src/labels (glyphs, label placement and
decluttering, independent of Protomaps and LuciadRIA).
The layer is a LayerGroup of two LuciadRIA layers, split the way MapLibre splits its work
between workers and the main thread:
- Fills and lines are rasterized per tile in a pool of web workers (
TileRasterizer, driven byProtomapsRasterModel) and shown by aRasterTileSetLayer. A worker decodes the tile, evaluates the style and draws every fill and line layer into anOffscreenCanvas; the main thread only uploads the finished RGBA image as a texture. The tiles of the raster tile set are the MapLibre tiles (the layer'sdetailFactoristileSize / 512, so LuciadRIA picks the same tiles MapLibre would); beyond the archive's maximum zoom the matching part of the deepest tile is drawn. Tiles are 512 px on every screen: RIA picks the level from the tile's size in CSS pixels, so larger tiles are only shown one level coarser, and a 3D map stops refining tiles whose visible part is smaller than about 0.4 times their pixel size (1024 px tiles left a 380 px tall phone map stuck at level 3). The tile set starts at zoom 2, with 4 x 4 tiles of 90 degrees, rather than with the single world tile of zoom 0: with that tile as the root, RIA's 3D map (2026.0) never refines beyond zoom 1 in whole regions of the globe on a phone-sized map, whatever the zoom (Singapore, Quito and the Indian Ocean stalled on a 390 x 382 px map while Belgium refined normally; a root at zoom 1 still stalls, at zoom 2 or 3 every tested place refines to zoom 16). TherootZoomoption changes it for experiments. RIA's raster layer keeps the tiles of neighbouring levels on screen until new ones arrive, so nothing flashes and nothing stalls. Measured withnpm run benchin a headed Chrome, an animated zoom over two levels (Berlin, zoom 12.5 to 14.5) spends 5.6 s in main-thread long tasks invectormode, the largest 1.2 s, and 0.2 s inrastermode, the largest 64 ms; a zoom level step goes from 1.5 s of long tasks to 55 ms, and panning or a level crossing without new tiles has none. With street names drawn glyph by glyph as well (see below) instead of as RIA complex strokes, no scenario has a long task left and the longest frame gap of a four-level zoom-in is 33 ms. RIA shows levelzfor map zoomsz ± 0.5and scales the images in between, so line widths are exact at integer zooms and up to 41 % off at the half levels, like any raster basemap.renderMode: 'vector'restores the previous behaviour, where every feature is a LuciadRIA feature that is re-stroked at each zoom level: crisp everywhere, but LuciadRIA tessellates and uploads each feature on the main thread, which stalls the map for hundreds of milliseconds whenever a zoom level loads. - Symbols (place names, POI labels, road shields, street names) are LuciadRIA features in a
FeatureLayer:ProtomapsTileStoreis a RIAStoreover the sameProtomapsTileSource(the PMTiles archive plus the worker pool, with a small cache of raw tiles so each tile is fetched once for both layers). The workers decode and match tiles against the style, keeping only the features a symbol layer accepts;LoadSpatiallyre-queries the store per integer zoom level, and RIA keeps the previous features on screen until the new ones are processed. Multi-part lines (tiles merge all lines with equal attributes into one feature with hundreds of parts) are split into features of at most 16 parts, so RIA can spread its work over frames in small units. ProtomapsPainterevaluates the compiled style per detail level and caches RIA style objects so the renderer can batch them. There are four detail levels per zoom level: a level change repaints every symbol, which is cheap now that fills and lines are raster tiles, and curved labels are laid out per level, so four levels keep their glyph spacing within 9 % while the map zooms in between. RIA's paint budget is set to 10 ms per frame (optionperformanceHints), which keeps frames short.- Street names follow their line glyph by glyph:
GlyphAtlasrasterizes each character once per font, size, colour and halo into a small canvas (letter and halo apart, so all halos of a label go below all its letters),LinePathplaces the glyphs along the line and the painter draws them as icons. The layout happens in screen pixels, on the line as it is seen through the map's view transformation: the glyph spacing is right whatever the scale, the latitude or the perspective of a tilted 3D view, and a label reads left to right on screen, so it flips when the map is turned round. Every run of the line the view can place is used, so a line that crosses the horizon of a 3D map is still labelled on the part of it that is on screen; on a 3D map the runs are also cut to the view plus half a view on every side, because the labels are laid out again whenever the camera moves, and a road that runs under a tilted camera, stretched to many screens by the perspective, would otherwise cost hundreds of placements nobody sees. A 2D map keeps the whole line, whose length the tile bounds, so labels are ready off screen when it pans. Each glyph is then anchored on the map at its own place, with a heading, so the renderer itself keeps the label on its line while the map pans, zooms and turns, without repainting. Labels are laid out again at every detail level (four per zoom level, so the spacing drifts by less than 9 % in between), and on a 3D map when the camera tilts or turns by more than 3 degrees or moves by more than 5 % of its height (a line's foreshortening depends on where it is on a tilted screen), at most every 250 ms. A line repeats its name everysymbol-spacingpixels of that screen path, or every second place on that grid with its shields on the ones in between, so the two layers interleave without knowing about each other; each repetition claims its boxes on its own, so one is dropped where it collides while the rest of the line stays named. Place names, POI labels and road shields are rasterized whole byLabelRasterizerinto canvases drawn as icons too. Either way the renderer decodes each image once and draws it any number of times: RIA's own HTML labels,drawTextand complex-stroke text all cost milliseconds per label per zoom level (DOM layout, a GPU canvas read back withgetImageData, or stroke discretization on the main thread).Declutterkeeps all symbols from overlapping: every symbol claims boxes per detail level (one per glyph for curved labels), with the style layer order as priority and MapLibre'stext-variable-anchorbehaviour for point labels. Road shields are the one exception to that order: they rank just above the topmost layer that draws text along a line, so a shield wins its place from street names however the style orders the two, while every other pair of layers keeps the order the style gives it. By default (htmlLabels,htmlShields) the text of point symbols and the road shields are drawn as RIA HTML labels instead of icons: crisper (DOM text on the pixel grid rather than a bitmap on a quad) and painted on top of every layer of the map, at the cost of DOM layout per label. An HTML shield is a box in the colours of its sprite icon (fill, border, border width and corner radius are sampled from the icon, so it looks like the icon, only sharper);roundedShields: falsesquares the corners. House numbers, which come by the thousand at high zoom, stay icons unlesshtmlAddressLabelsis set. HTML labels still claim their boxes in the same declutter. Zoom dependent symbol values are evaluated at the zoom measured from the view at each symbol, not at the detail level's: exact within a level and, on the 3D globe, right for the latitude (the map scale there is in ground meters while MapLibre zooms are Web Mercator ones, 0.7 levels apart at 52° north). The boxes are the map ground under each symbol's rectangle on screen, corner by corner, so upright (non-draped) labels declutter correctly on a tilted 3D map. The declutter and placement code knows nothing about Protomaps or LuciadRIA, so it can serve other labels too. - Symbol layers that only repeat an icon along lines, such as one-way arrows, are drawn into the
raster tiles with the fills and lines: the sprite sheet is handed to every worker as an
ImageBitmap, and each icon is cut out with a transparent border first, since drawing a rotated and scaled icon straight from the sheet bleeds its neighbours in. WithlabelsOnlythe tiles keep just these symbol layers. StyleExpressionandMvtDecoderare small, dependency-free implementations of the MapLibre expression language (including legacy filters and functions) and the MVT format.ForeignStyleSchemaadapts styles written for OpenMapTiles or Mapbox Streets to the Protomaps tile schema.
Workers and bundlers
The decoding workers are started with new Worker(new URL('./ProtomapsWorker.js', import.meta.url), { type: 'module' }),
which webpack 5 and Vite bundle automatically. If a bundler cannot handle it, decoding and
rasterizing fall back to the main thread; pass workers: 0 in the layer options to force that.
Demo map
demo/ holds a small Vite app that renders the layer straight from src/, so changes are visible
with hot reload and no compile/pack/install round trip:
cp demo/.env.example demo/.env.local # fill in VITE_LUCIAD_RIA_LICENSE (the RIA license text)
npm run demo # http://localhost:5174The panel lets you switch PMTiles archives, the bundled style JSONs in demo/styles/ (or a custom
URL), locale, render mode, worker count and the layer options, the spacing of repeated line labels
and the zoom below which shielded roads drop their name, the flags (mode, shape, size and
whether states and cities get a Wikidata one; &flagBase=… in the URL serves the country flags
from somewhere else), put Esri World Imagery under the
layer (to try the satellite style with labels only), and jump to a few places. Settings are kept in
the URL. window.map and window.protomaps() are available in the console.
Benchmarking
With the demo running, npm run bench drives it in headless Chrome and prints, per navigation
step, the main-thread long tasks, the largest task, the largest frame gap and the painter's work.
npm run bench -- level-full,level-full "&budget=5" runs chosen scenarios (a scenario listed
twice measures a cold and a warm run) with extra demo URL parameters (mode: raster or vector,
budget: RIA paint budget per frame in ms, parts: maximum parts per line feature, tess=1:
RIA's fast polygon tessellation). node dev/bench/tasks.js profiles one navigation step and lists
the longest main-thread tasks with the functions behind them. npm run shots -- raster saves
screenshots to dev/bench/. node dev/bench/refine.js 103.85 1.29 390 382 emulates a phone-sized
3D map flying to a place and prints the raster levels RIA requests, to catch the refinement
stalls described above. HEADED=1 uses a visible Chrome with the real GPU (headless renders
in software). Set CHROME if Chrome is not in the default location.
Style preparation
node dev/bake_opacity.js demo/styles/ingplus.json ... replaces translucent fills and lines by
opaque colours: each colour is blended at the layer's opacity with the colour expected beneath
(the earth fill, or the background), zoom dependent opacities become zoom dependent colours,
and a fade to zero becomes the layer's minzoom or maxzoom. Translucent polygons show where
they overlap each other; opaque ones do not. The ING+ styles are baked this way.
Building and publishing
Apps that install the package from the packed tarball (file:.../build/protomaze-protomaps-ria-<version>.tgz)
need a new version number for every new build: webpack treats node_modules as unchanged while
the package version stays the same, so a reinstalled tarball with the old version keeps serving
the old code until the version changes. Bump the version, compile, npm pack in build/ and
npm install <the new tarball> in the app.
npm run compile # tsc + copy package.json/README + minify, into build/
npm run pub # publish to the local Verdaccio registry (localhost:4873)
npm run deploy # publish to npmjs.org