PMTiles for Leaflet
Why use Leaflet?
Leaflet.js is the simplest full-featured map library for web browsers, with a wide range of plugins. If you're looking for a WebGL-powered library with a smooth-zooming experience like Google or Apple Maps, check out MapLibre GL.
Raster PMTiles
The base distribution of Leaflet only supports raster images for tiled data sources.
Add a raster PMTiles archive using the pmtiles
library:
import { PMTiles, leafletRasterLayer } from 'pmtiles';
const p = new PMTiles('https://example.com/data.pmtiles');
leafletRasterLayer(p).addTo(map)
import { PMTiles, leafletRasterLayer } from 'pmtiles';
const p = new PMTiles('https://example.com/data.pmtiles');
leafletRasterLayer(p).addTo(map)
Vector PMTiles
Protomaps publishes a lightweight Leaflet plugin, protomaps-leaflet, that implements vector drawing and text labels built on the Canvas API and Web Fonts.
Note that the protomaps-leaflet library is designed for non-interactive layers, because it renders vector tiles to Canvas (image) elements.
For basemap display as a substitute for server-rendered tiles, see Basemaps for Leaflet.
For fully interactive vector overlay tiles you should use MapLibre GL JS.
Custom Vector Data
If you're bringing your own data created with a tool like tippecanoe, you'll need to write your own Paint Rules and Label Rules to visualize your tiles. You can re-use common visualization patterns with custom Symbolizers.
My first rule
In protomaps-leaflet, the layers of a map are expressed using one or more Rules. A Rule is a JavaScript object that specifies a layer of data in the vector tile source, such as water
, buildings
, or places
, as well as a Symbolizer, which determines how the geographic features are drawn to Canvas.
A very simple "hello world" map with one Rule is below.
let PAINT_RULES = [
{
dataLayer:"water",
symbolizer:new protomapsL.PolygonSymbolizer({fill:"steelblue"})
}
];
let LABEL_RULES = []; // ignore for now
protomapsL.leafletLayer({
url:URL,
paintRules:PAINT_RULES,
labelRules:LABEL_RULES
}).addTo(map)
let PAINT_RULES = [
{
dataLayer:"water",
symbolizer:new protomapsL.PolygonSymbolizer({fill:"steelblue"})
}
];
let LABEL_RULES = []; // ignore for now
protomapsL.leafletLayer({
url:URL,
paintRules:PAINT_RULES,
labelRules:LABEL_RULES
}).addTo(map)
My first symbolizer
The previous code uses the class protomapsL.PolygonSymbolizer
- but what does that do? PolygonSymbolizer
is a pre-made symbolizer that takes a fill color as an argument, and conforms to the PaintSymbolizer
Interface.
A PaintSymbolizer
just needs to have a method draw
:
class MyWaterSymbolizer {
draw(context,geom,z,feature) {
context.fillStyle = "dodgerblue"
context.beginPath()
for (var poly of geom) {
for (var p = 0; p < poly.length-1; p++) {
let pt = poly[p]
if (p == 0) context.moveTo(pt.x,pt.y)
else context.lineTo(pt.x,pt.y)
}
}
context.fill()
}
}
let PAINT_RULES = [
{
dataLayer:"water",
symbolizer:new MyWaterSymbolizer()
}
]
class MyWaterSymbolizer {
draw(context,geom,z,feature) {
context.fillStyle = "dodgerblue"
context.beginPath()
for (var poly of geom) {
for (var p = 0; p < poly.length-1; p++) {
let pt = poly[p]
if (p == 0) context.moveTo(pt.x,pt.y)
else context.lineTo(pt.x,pt.y)
}
}
context.fill()
}
}
let PAINT_RULES = [
{
dataLayer:"water",
symbolizer:new MyWaterSymbolizer()
}
]
The above dozen lines of code reads the vertices from geom
and draws a polygon using the Canvas 2D methods like beginPath
and fill
. The coordinates x and y are all in CSS pixels; all scaling and transformation is handled for you by the library.
Multiple rules
When multiple rules are specified, drawing proceeds from the start to the end. Rules that come later will be drawn over objects that come earlier. Let's add another symbolizer that draws point data from the layer places
with a fill + a stroke, changes the color based on the feature properties, and only takes effect at zooms 12 and above:
class MyPlaceSymbolizer {
draw(context,geom,z,feature) {
// console.log(properties)
let pt = geom[0][0]
var fill = "palegreen"
if (feature.props.place == "suburb") fill = "lightgreen"
if (feature.props.place == "city") fill = "mediumseagreen"
context.fillStyle = fill
context.strokeStyle = "black"
context.beginPath()
context.arc(pt.x,pt.y,4,0,2*Math.PI)
context.stroke()
context.fill()
}
}
let PAINT_RULES = [
{
dataLayer:"water",
symbolizer:new MyWaterSymbolizer()
},
{
dataLayer:"places",
symbolizer:new MyPlaceSymbolizer(),
minzoom: 12
}
]
class MyPlaceSymbolizer {
draw(context,geom,z,feature) {
// console.log(properties)
let pt = geom[0][0]
var fill = "palegreen"
if (feature.props.place == "suburb") fill = "lightgreen"
if (feature.props.place == "city") fill = "mediumseagreen"
context.fillStyle = fill
context.strokeStyle = "black"
context.beginPath()
context.arc(pt.x,pt.y,4,0,2*Math.PI)
context.stroke()
context.fill()
}
}
let PAINT_RULES = [
{
dataLayer:"water",
symbolizer:new MyWaterSymbolizer()
},
{
dataLayer:"places",
symbolizer:new MyPlaceSymbolizer(),
minzoom: 12
}
]
Adding text labels
The obvious next step is to put labels on our map. Each of those place
features in the above example has a name
like "London", "Covent Garden" within properties
that we can use to draw on the map - try commenting out the logging statement to see them in your console.
The Canvas 2D context object has fillText
and strokeText
methods we can use to draw the text label, with bottom left corner at the X,Y position of the point:
class MyPlaceSymbolizer {
draw(context,geom,z,feature) {
let pt = geom[0][0]
var font = "12px sans-serif"
if (feature.props.place == "suburb") font = "500 14px sans-serif"
if (feature.props.place == "city") font = "800 16px sans-serif"
context.fillStyle = "darkslategray"
context.font = font
context.fillText(feature.props.name, pt.x,pt.y)
}
}
class MyPlaceSymbolizer {
draw(context,geom,z,feature) {
let pt = geom[0][0]
var font = "12px sans-serif"
if (feature.props.place == "suburb") font = "500 14px sans-serif"
if (feature.props.place == "city") font = "800 16px sans-serif"
context.fillStyle = "darkslategray"
context.font = font
context.fillText(feature.props.name, pt.x,pt.y)
}
}
Well, that doesn't look as nice as we hoped. There are a few issues:
Labels can be arbitrarily wide based on the length of the text, and the tile-based map rendering might cut off the text midway.
Labels overlap with each other.
To solve this, there's another Symbolizer interface called the LabelSymbolizer
.
The LabelSymbolizer
LabelSymbolizers have a more complex interface than PaintSymbolizers. Instead of having a draw
method, they have a place
method. place
returns a bounding box that is tested against an internal layout. place
also returns a closure which is executed against the Canvas2D context only if the label is successfully placed.
LabelRules go into a separate list of passed into the layer via the label_rules
key. When placing text labels, we usually need to determine the height and width of text to find the bounding box; this is possible via the canvas measureText
method. There is a "scratch" canvas context provided via layout.scratch
used for doing this work.
class MyPlaceSymbolizer {
place(layout,geom,feature) {
let pt = geom[0][0]
let name = feature.props.name
var font = "12px sans-serif"
if (feature.props.place == "suburb") font = "500 14px sans-serif"
if (feature.props.place == "city") font = "800 16px sans-serif"
layout.scratch.font = font
let metrics = layout.scratch.measureText(name)
let width = metrics.width
let ascent = metrics.actualBoundingBoxAscent
let descent = metrics.actualBoundingBoxDescent
let bbox = {minX:pt.x-width/2,minY:pt.y-ascent,maxX:pt.x+width/2,maxY:pt.y+descent}
let draw = ctx => {
ctx.font = font
ctx.fillStyle = "darkslategray"
ctx.fillText(name,-width/2,0)
}
return [{anchor:pt,bboxes:[bbox],draw:draw}]
}
}
let LABEL_RULES = [
{
dataLayer:"places",
symbolizer:new MyPlaceSymbolizer()
}
]
class MyPlaceSymbolizer {
place(layout,geom,feature) {
let pt = geom[0][0]
let name = feature.props.name
var font = "12px sans-serif"
if (feature.props.place == "suburb") font = "500 14px sans-serif"
if (feature.props.place == "city") font = "800 16px sans-serif"
layout.scratch.font = font
let metrics = layout.scratch.measureText(name)
let width = metrics.width
let ascent = metrics.actualBoundingBoxAscent
let descent = metrics.actualBoundingBoxDescent
let bbox = {minX:pt.x-width/2,minY:pt.y-ascent,maxX:pt.x+width/2,maxY:pt.y+descent}
let draw = ctx => {
ctx.font = font
ctx.fillStyle = "darkslategray"
ctx.fillText(name,-width/2,0)
}
return [{anchor:pt,bboxes:[bbox],draw:draw}]
}
}
let LABEL_RULES = [
{
dataLayer:"places",
symbolizer:new MyPlaceSymbolizer()
}
]
The two core interfaces of PaintSymbolizer and LabelSymbolizer encompass all of the functionality of protomaps-leaflet. There are many pre-built symbolizers to handle advanced labeling features such as line breaking, dynamic label positioning or text on slanted paths, but if those are not sufficient you are at liberty to implement your own.
Drawing order
protomaps-leaflet always operates in this order:
Fetch the tile data for the given display tile.
Run all LabelSymbolizers from first to last, filling the labels quadtree with bounding boxes.
Run all PaintSymbolizers from first to last, painting to the canvas from back to front.
Run all Label closures - the ordering is irrelevant as labels are non-overlapping.
The internal ordering of step 2 has a large effect on which labels ultimately appear on the map. High priority labels should appear in separate rules before low priority labels; thus a "city" rule should appear before a "neighbourhood" rule. If there is intended label priority within a Rule, specify a sort
function on a Rule - this will order the features internally before label layout happens.
Finally, there is a cross-tile label resolution step where a label with higher rule precedence can knock out labels with lower precedence when an adjacent tile is loaded. This uses rule precedence only, meaning that the final label layout is non-determinstic; it depends on which tile was loaded first.