How to Add Interactivity and Popups to a Web Map
On this page
Problem statement
Clicking a feature and seeing its attributes is the first thing anyone asks of a web map, and with vector tiles it has four complications that do not exist with a plain GeoJSON layer:
- The attributes must be in the tiles. They are not free β two short string fields added a quarter to a measured tile.
- Features are clipped and duplicated across tile boundaries, so one building can be two hits.
queryRenderedFeaturesonly sees what is drawn, so anything filtered or off-screen is invisible to it.- Geometry from a tile is clipped and quantised, so measurements taken from it are wrong.
Everything below was run: Rutland's 5,513 OpenStreetMap buildings, tiled with GDAL into PMTiles, drawn by MapLibre GL JS 4 in headless Chromium over central Oakham at zoom 16, with the clicks and hovers made by Playwright's mouse.
Quick answer
map.on("click", "buildings-fill", (e) => {
const feature = e.features[0];
if (!feature) return;
new maplibregl.Popup({ closeButton: true })
.setLngLat(e.lngLat)
.setHTML(`<strong>${escapeHtml(feature.properties.name ?? "Building")}</strong><br>
${escapeHtml(feature.properties.type ?? "unknown type")}`)
.addTo(map);
});
map.on("mouseenter", "buildings-fill", () => { map.getCanvas().style.cursor = "pointer"; });
map.on("mouseleave", "buildings-fill", () => { map.getCanvas().style.cursor = ""; });
Clicking the middle of Oakham School Chapel with that handler attached:
result = session(QUICK_HANDLER, actions=[("click", CHAPEL)])
print("features in e.features:", result["clicks"][0])
print("popup text:", result["popup"])
features in e.features: 1
popup text: Oakham School Chapel
chapel
Γ
e.features contains only features from the layers named in the handler, already filtered by the layer's filter and zoom range β which is usually what you want and occasionally not.
Ids in the tiles, deduplicated hits, feature state
1. Include an identifier in the tiles
A stable feature id enables three things: deduplicating clipped features, highlighting on hover with feature state, and fetching full detail from an API without embedding it in every tile. It is the single most valuable property to include β and the tiles here did not have one:
for label, source in (("as tiled by GDAL", PMTILES), ("promoteId: 'osm_id'", PROMOTED)):
ids = session(source=source, evaluate="map.queryRenderedFeatures({layers: ['buildings-fill']}).map(f => f.id)")["value"]
print(f"{label:20} {len(ids)} pieces in view, {len(set(map(str, ids)))} distinct ids, first id {ids[0]!r}")
as tiled by GDAL 142 pieces in view, 1 distinct ids, first id None
promoteId: 'osm_id' 142 pieces in view, 135 distinct ids, first id '1352918786'
GDAL 3.12 wrote the Geofabrik shapefile's buildings without MVT feature ids, so every piece reported id as undefined β 142 pieces, one "id". promoteId tells MapLibre to use a property as the id instead, and osm_id is exactly that: 135 buildings, as strings.
2. Deduplicate the hits
A feature crossing a tile boundary is clipped into both tiles, so it is in the rendered set twice β here 142 pieces for 135 buildings. Whether a click sees both depends on how you query:
const seen = new Set();
const unique = e.features.filter((f) => {
const key = f.id ?? JSON.stringify(f.properties);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
COUNT_DUPES = """(() => { const f = map.queryRenderedFeatures({layers: ['buildings-fill']});
const n = {}; f.forEach(x => n[x.id] = (n[x.id] || 0) + 1);
return Object.keys(n).filter(k => n[k] > 1); })()"""
dupes = session(source=PROMOTED, evaluate=COUNT_DUPES)["value"]
spots = [tuple(buildings.loc[buildings["osm_id"] == i].representative_point().iloc[0].coords[0]) for i in dupes]
x, y = spots[0]
box = """(() => { const p = map.project([X, Y]);
return map.queryRenderedFeatures([[p.x - 15, p.y - 15], [p.x + 15, p.y + 15]], {layers: ['buildings-fill']})
.filter(f => f.id === 'ID').length; })()""".replace("X", str(x)).replace("Y", str(y)).replace("ID", dupes[0])
result = session(DEDUPE_HANDLER, source=PROMOTED, actions=[("click", p) for p in spots], evaluate=box)
print(f"{len(dupes)} buildings are in two tiles; clicking each: {result['clicks']} features per click")
print(f"a 30-pixel box query around the first: {result['value']} pieces with its id")
7 buildings are in two tiles; clicking each: [1, 1, 1, 1, 1, 1, 1] features per click
a 30-pixel box query around the first: 2 pieces with its id
A point query β which is what a click handler gets β was answered from the one tile containing the point, so each click on a duplicated building returned it once. A box query, the right tool for a finger-sized hit area on a touch screen, returned both pieces. Deduplicate whenever the query is an area, and keep it in the click handler as cheap insurance. Without an id the fallback is hashing the properties, which fails when two genuinely different features share attributes β in Rutland, 3,583 buildings have no type and no name, so their property sets are identical except for osm_id.
3. Use feature state for hover, not a filter
map.setFeatureState({ source: "buildings", sourceLayer: "buildings", id }, { hover: true });
"fill-opacity": ["case", ["boolean", ["feature-state", "hover"], false], 0.9, 0.5]
Feature state changes a paint property without re-evaluating the whole layer. It requires an id β and without one it does not quietly do nothing, it complains:
for label, source in (("no ids", PMTILES), ("promoteId", PROMOTED)):
js = """(async () => { const f = map.queryRenderedFeatures({layers: ['buildings-fill']})[0];
map.setFeatureState({source: 'buildings', sourceLayer: 'buildings', id: f.id}, {hover: true});
await new Promise(r => setTimeout(r, 500));
return map.queryRenderedFeatures({layers: ['buildings-fill']}).filter(x => x.state.hover).length; })()"""
result = session(source=source, evaluate=js)
print(f"{label:10} features now hovered: {result['value']}, map errors {result['errors'] or '-'}")
no ids features now hovered: 142, map errors ['The feature id parameter must be provided.']
promoteId features now hovered: 1, map errors -
With no id, MapLibre raised "The feature id parameter must be provided." through the map's error event β invisible unless you listen for it β and then did something worse than nothing: every one of the 142 pieces in view reported the hover state, because with no id they all share the same missing key. Hovering one building would light up the whole map.
4. Fetch detail rather than embedding it
Putting a full address, description and history in every tile multiplies the pyramid. Putting an id and fetching the rest on click costs one request per interaction:
for name in ("id_only", "with_strings"):
with open(f"data/{name}.pmtiles", "rb") as f:
tile = Reader(MmapSource(f)).get(15, 16317, 10723)
print(f"{name:13} archive {Path(f'data/{name}.pmtiles').stat().st_size / 1e3:6.0f} kB, "
f"Oakham tile z15 {len(tile):,} bytes")
id_only archive 754 kB, Oakham tile z15 5,759 bytes
with_strings archive 833 kB, Oakham tile z15 7,181 bytes
Two short strings, type and name, added 25% to the central Oakham tile and 10% to the archive. That also keeps the detail current without regenerating tiles.
5. Never measure tile geometry
Geometry in a vector tile is clipped at the tile edge and quantised to the tile grid. At the default extent of 4096:
lat = math.radians(52.67)
for z in (14, 15):
tile_m = 40075016.686 * math.cos(lat) / 2 ** z
print(f"zoom {z}: a tile is {tile_m:,.0f} m across, one unit of 4096 is {tile_m / 4096 * 100:.0f} cm")
zoom 14: a tile is 1,483 m across, one unit of 4096 is 36 cm
zoom 15: a tile is 742 m across, one unit of 4096 is 18 cm
Computing an area or a length from that gives a clipped, snapped answer β Example 3 measures one. Send the id to a service that has the real geometry.
Click, hover and highlight together
The data used on this page
Rutland's buildings from Geofabrik's OpenStreetMap extract of 1 January 2026 (Β© OpenStreetMap contributors, ODbL), tiled twice with GDAL β with osm_id only, and with type and name as well β served from a local range-capable server and drawn by MapLibre GL JS 4 with the PMTiles 3 client in headless Chromium through Playwright.
import json
import math
import re
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import geopandas as gpd
import requests
from osgeo import gdal
from playwright.sync_api import sync_playwright
from pmtiles.reader import MmapSource, Reader
gdal.UseExceptions()
Path("data").mkdir(exist_ok=True)
if not Path("data/rutland.shp.zip").exists():
Path("data/rutland.shp.zip").write_bytes(requests.get(
"https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
SHP = "/vsizip/data/rutland.shp.zip/gis_osm_buildings_a_free_1.shp"
buildings = gpd.read_file(SHP)
for name, fields in (("id_only", ["osm_id"]), ("with_strings", ["osm_id", "type", "name"])):
if not Path(f"data/{name}.pmtiles").exists():
gdal.VectorTranslate(f"data/{name}.pmtiles", SHP, format="PMTiles", layerName="buildings",
selectFields=fields, datasetCreationOptions=["MINZOOM=10", "MAXZOOM=15"])
ARCHIVE = Path("data/with_strings.pmtiles").read_bytes()
PAGES = {}
class Host(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def reply(self, status, body=b"", headers=()):
self.send_response(status)
for key, value in headers:
self.send_header(key, value)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def do_OPTIONS(self):
self.reply(204, headers=[("Access-Control-Allow-Origin", "*"), ("Access-Control-Allow-Headers", "Range")])
def do_GET(self):
if self.path.startswith("/page/"):
return self.reply(200, PAGES[self.path[6:]].encode(), [("Content-Type", "text/html")])
if not self.path.endswith(".pmtiles"):
return self.reply(404)
headers = [("Access-Control-Allow-Origin", "*"), ("Access-Control-Expose-Headers", "Content-Range, ETag")]
found = re.match(r"bytes=(\d+)-(\d*)", self.headers.get("Range") or "")
if found:
first = int(found.group(1))
last = min(int(found.group(2) or len(ARCHIVE) - 1), len(ARCHIVE) - 1)
return self.reply(206, ARCHIVE[first:last + 1], headers + [("Content-Range", f"bytes {first}-{last}/{len(ARCHIVE)}")])
return self.reply(200, ARCHIVE, headers)
do_HEAD = do_GET
server = ThreadingHTTPServer(("127.0.0.1", 0), Host)
server.daemon_threads = True
threading.Thread(target=server.serve_forever, daemon=True).start()
BASE = f"http://127.0.0.1:{server.server_address[1]}"
PMTILES = {"type": "vector", "url": f"pmtiles://{BASE}/buildings.pmtiles"}
PROMOTED = {**PMTILES, "promoteId": "osm_id"}
CHAPEL = tuple(buildings.loc[buildings["osm_id"] == "89658300"].representative_point().iloc[0].coords[0])
centre = gpd.GeoSeries.from_xy([-0.7265], [52.67], crs=4326).to_crs(27700).iloc[0]
metric = buildings.to_crs(27700)
nearest = metric[(metric["osm_id"] != "89658300")].distance(centre).idxmin()
HOVER_AT = tuple(buildings.loc[[nearest]].representative_point().iloc[0].coords[0])
HOVER_PAINT = {"fill-color": ["case", ["boolean", ["feature-state", "selected"], False], "#ef4444",
["boolean", ["feature-state", "hover"], False], "#14b8a6", "#0ea5e9"],
"fill-opacity": ["interpolate", ["linear"], ["zoom"],
12, ["case", ["boolean", ["feature-state", "hover"], False], 0.85, 0.3],
16, ["case", ["boolean", ["feature-state", "hover"], False], 0.85, 0.6]]}
def tile_of(lon, lat, z):
n = 2 ** z
return z, int((lon + 180) / 360 * n), int((1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n)
TEMPLATE = """<!doctype html><meta charset="utf-8">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.css">
<style>html,body,#map { height:100%; margin:0 }</style><div id="map"></div>
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pmtiles@3/dist/pmtiles.js"></script>
<script>
window.errors = []; window.clicks = []; window.unique = []; window.ids = [];
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g,
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
const protocol = new pmtiles.Protocol(); maplibregl.addProtocol("pmtiles", protocol.tile);
window.map = new maplibregl.Map({ container: "map", center: [-0.7265, 52.6700], zoom: 16, style: STYLE });
map.on("error", e => window.errors.push(String(e && e.error && e.error.message)));
map.on("load", () => { HANDLER });
</script>"""
def session(handler="", source=PMTILES, paint=None, actions=(), evaluate=None, wait=6):
"""Open one map in Chromium, run mouse actions, and report what the page saw."""
layer = {"id": "buildings-fill", "type": "fill", "source": "buildings", "source-layer": "buildings",
"paint": paint or {"fill-color": "#0ea5e9", "fill-opacity": 0.6}}
style = {"version": 8, "sources": {"buildings": source}, "layers": [layer]}
name = f"p{len(PAGES)}"
PAGES[name] = TEMPLATE.replace("STYLE", json.dumps(style)).replace("HANDLER", handler)
with sync_playwright() as p:
browser = p.chromium.launch()
tab = browser.new_page(viewport={"width": 800, "height": 600})
tab.goto(f"{BASE}/page/{name}")
time.sleep(wait)
for kind, where in actions:
point = tab.evaluate(f"map.project({list(where)})")
x, y = point["x"], point["y"]
(tab.mouse.move if kind == "hover" else tab.mouse.click)(x, y)
time.sleep(1)
value = tab.evaluate(evaluate) if evaluate else None
time.sleep(0.5)
out = {key: tab.evaluate(f"window.{key}") for key in ("errors", "clicks", "unique", "ids")}
out["popup"] = tab.evaluate("document.querySelector('.maplibregl-popup-content')?.innerText.trim() ?? null")
out["value"] = value
browser.close()
return out
QUICK_HANDLER = """map.on("click", "buildings-fill", (e) => {
window.clicks.push(e.features.length);
const feature = e.features[0];
new maplibregl.Popup({ closeButton: true }).setLngLat(e.lngLat)
.setHTML(`<strong>${escapeHtml(feature.properties.name ?? "Building")}</strong><br>
${escapeHtml(feature.properties.type ?? "unknown type")}`).addTo(map);
});"""
DEDUPE_HANDLER = """map.on("click", "buildings-fill", (e) => {
const seen = new Set();
const unique = e.features.filter((f) => { const key = f.id ?? JSON.stringify(f.properties);
if (seen.has(key)) return false; seen.add(key); return true; });
window.clicks.push(e.features.length); window.unique.push(unique.length);
window.ids.push(e.features.map(f => f.id));
});"""
print(f"{len(buildings):,} buildings; archive {len(ARCHIVE) / 1e3:.0f} kB served from 127.0.0.1 on a free port")
5,513 buildings; archive 833 kB served from 127.0.0.1 on a free port
Example 1 β click, hover and highlight together
The function below is plain JavaScript; here Playwright hovers over one building, then clicks the chapel:
ADD_INTERACTIVITY = """
function addInteractivity(map, { layerId, sourceId, sourceLayer, titleField = "name" }) {
let hoveredId = null;
map.on("mousemove", layerId, (e) => {
map.getCanvas().style.cursor = "pointer";
if (!e.features.length) return;
const id = e.features[0].id;
if (id === undefined) return; // no id in the tiles
if (hoveredId !== null) map.setFeatureState({ source: sourceId, sourceLayer, id: hoveredId }, { hover: false });
hoveredId = id;
map.setFeatureState({ source: sourceId, sourceLayer, id }, { hover: true });
window.ids.push(id);
});
map.on("mouseleave", layerId, () => {
map.getCanvas().style.cursor = "";
if (hoveredId !== null) map.setFeatureState({ source: sourceId, sourceLayer, id: hoveredId }, { hover: false });
hoveredId = null;
});
map.on("click", layerId, (e) => {
const seen = new Set();
const unique = e.features.filter((f) => {
const key = f.id ?? JSON.stringify(f.properties);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
if (!unique.length) return;
const feature = unique[0];
window.clicks.push(e.features.length);
new maplibregl.Popup().setLngLat(e.lngLat)
.setHTML(`<strong>${escapeHtml(feature.properties[titleField] ?? "Feature")}</strong>
<div>${escapeHtml(feature.properties.type ?? "")} Β· OSM ${escapeHtml(feature.id)}</div>`)
.addTo(map);
});
}
addInteractivity(map, { layerId: "buildings-fill", sourceId: "buildings", sourceLayer: "buildings" });
"""
result = session(ADD_INTERACTIVITY, source=PROMOTED, paint=HOVER_PAINT,
actions=[("hover", HOVER_AT), ("click", CHAPEL)],
evaluate="map.getFeatureState({source: 'buildings', sourceLayer: 'buildings', id: window.ids.at(-1)})")
print("hovered ids:", result["ids"], "| its feature state:", result["value"])
print("popup:", result["popup"], "| errors:", result["errors"])
hovered ids: ['891591979', '89658300'] | its feature state: {'hover': True}
popup: Oakham School Chapel
chapel Β· OSM 89658300
Γ | errors: []
escapeHtml is not optional. Feature properties come from data you may not control, and what setHTML does with markup in them is easy to get wrong:
js = """(async () => { window.s = 0; window.i = 0;
new maplibregl.Popup().setLngLat([-0.7265, 52.67]).setHTML('<scr' + 'ipt>window.s = 1</scr' + 'ipt>').addTo(map);
new maplibregl.Popup().setLngLat([-0.7265, 52.67]).setHTML('<img src=x onerror="window.i = 1">').addTo(map);
await new Promise(r => setTimeout(r, 1000)); return {script_ran: window.s, onerror_ran: window.i}; })()"""
print(session(evaluate=js)["value"])
{'script_ran': 0, 'onerror_ran': 1}
A <script> element in a name field does not run β markup inserted as HTML never executes script tags β which makes it easy to conclude that setHTML is safe. An <img> with an onerror attribute does run, and that is the one an attacker would use. Escape every property value.
Example 2 β a paint expression that responds to state
{
id: "buildings-fill",
type: "fill",
source: "buildings",
"source-layer": "buildings",
paint: {
"fill-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#ef4444",
["boolean", ["feature-state", "hover"], false], "#14b8a6",
"#0ea5e9",
],
"fill-opacity": [
"interpolate", ["linear"], ["zoom"],
12, ["case", ["boolean", ["feature-state", "hover"], false], 0.85, 0.3],
16, ["case", ["boolean", ["feature-state", "hover"], false], 0.85, 0.6],
],
},
}
["boolean", ["feature-state", "hover"], false] supplies the default for a feature whose state was never set. The opacity is built inside out on purpose: a ["zoom"] expression may only be the input of the outermost interpolate or step, so the zoom ramp goes on the outside and the hover case inside each stop. Both choices, tested:
variants = {
"as above": HOVER_PAINT,
"no boolean wrapper": {"fill-color": "#0ea5e9", "fill-opacity": ["case", ["feature-state", "hover"], 0.9, 0.5]},
"zoom inside case": {"fill-color": "#0ea5e9", "fill-opacity": ["case", ["boolean", ["feature-state", "hover"], False], 0.85,
["interpolate", ["linear"], ["zoom"], 12, 0.3, 16, 0.6]]},
}
for label, paint in variants.items():
result = session(source=PROMOTED, paint=paint, evaluate="map.getLayer('buildings-fill') ? map.queryRenderedFeatures({layers: ['buildings-fill']}).length : 'no layer'")
print(f"{label:19} rendered: {result['value']} errors: {result['errors'] or '-'}")
as above rendered: 142 errors: -
no boolean wrapper rendered: 142 errors: -
zoom inside case rendered: no layer errors: ['layers[0].paint.fill-opacity: "zoom" expression may only be used as input to a top-level "step" or "interpolate" expression.']
Written the other way round β a case with a zoom interpolate as one of its branches β MapLibre rejects the whole layer with the message above, and nothing draws. That is how the same mistake was found in an earlier version of this page. The missing boolean wrapper, by contrast, was accepted by MapLibre 4 without complaint; the wrapper is still worth writing, because it states the default instead of relying on how the renderer treats an unset value.
Example 3 β the detail endpoint that popups fetch
import pandas as pd
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
app = FastAPI()
STATE = {"data": buildings.to_crs(27700)}
@app.get("/api/features/{feature_id}")
def feature_detail(feature_id: str):
"""Full attributes and real geometry β neither is in the tiles."""
match = STATE["data"][STATE["data"]["osm_id"] == feature_id]
if match.empty:
raise HTTPException(404, "feature not found")
row = match.iloc[0]
centroid = match.to_crs(4326).geometry.iloc[0].centroid
return {"id": feature_id,
"properties": {k: (None if pd.isna(v) else getattr(v, "item", lambda: v)())
for k, v in row.drop("geometry").items()},
"area_m2": round(float(row.geometry.area), 1),
"perimeter_m": round(float(row.geometry.length), 1),
"centroid": [round(centroid.x, 6), round(centroid.y, 6)]}
client = TestClient(app)
detail = client.get("/api/features/89658300").json()
print(detail["properties"]["name"], "|", detail["area_m2"], "mΒ² |", detail["perimeter_m"], "m perimeter")
print("unknown id:", client.get("/api/features/1").status_code)
import gzip
import mapbox_vector_tile as mvt
from shapely.geometry import shape
with open("data/with_strings.pmtiles", "rb") as f:
tile = Reader(MmapSource(f)).get(*tile_of(*CHAPEL, 14))
tile = gzip.decompress(tile) if tile[:2] == b"\x1f\x8b" else tile
decoded = mvt.decode(tile)["buildings"]["features"]
chapel = next(f for f in decoded if f["properties"].get("osm_id") == "89658300")
units = shape(chapel["geometry"]).area
tile_m = 40075016.686 * math.cos(math.radians(52.67)) / 2 ** 14
print(f"the same chapel from its zoom 14 tile: {units * (tile_m / 4096) ** 2:.1f} mΒ² "
f"({units:,.0f} square tile units before conversion)")
Oakham School Chapel | 347.5 mΒ² | 91.5 m perimeter
unknown id: 404
the same chapel from its zoom 14 tile: 344.7 mΒ² (2,629 square tile units before conversion)
Area and perimeter are computed from the source geometry in British National Grid: 347.5 mΒ² for the chapel. Measured from its zoom-14 tile, with every vertex snapped to a 36 cm grid, it came out at 344.7 mΒ² β and a feature that crossed a tile edge would have been cut short as well. Computing it in the browser from tile geometry would be clipped, quantised and, unless converted, in tile units or degrees.
Queries see only what is drawn
Why queryRenderedFeatures sees only what is drawn
MapLibre answers queries from its render state, not from the tile data. So a feature excluded by the layer's filter, or outside the layer's zoom range, or in a tile that has not loaded, does not appear. That is usually what you want: clicking should hit what the user can see.
When it is not β searching for a feature currently off-screen, or counting everything in the source β querySourceFeatures reads the loaded tiles directly, ignoring filters and styling. It still only sees loaded tiles, so it is not a substitute for querying the source data.
Why duplicates appear
Each vector tile must render independently, so geometry crossing a boundary is stored in both tiles, each copy with a buffer beyond the tile edge. A query over an area that covers both copies returns one hit from each; a point query in MapLibre 4 was answered from one tile. The duplicates have identical properties and different geometry β each is the clipped part in its own tile. Deduplicating by id is exact; deduplicating by properties fails when two real features share attributes, which for buildings is common.
Why feature state exists
The obvious way to highlight on hover is to update a filter or a paint expression referencing the hovered id. Both make MapLibre re-evaluate the layer. Feature state is a separate per-feature key-value store that paint expressions can read, and changing it updates only the affected feature. The requirement is a feature id, in the tiles or promoted from a property β and without one, setFeatureState reports an error on the map's error event and the state lands on every feature that lacks an id.
Why to fetch detail rather than embed it
Attributes are stored per tile, so a description repeated across every zoom level is stored at every zoom level; two short strings added 25% to a busy tile here. An id plus a detail endpoint costs one request per click and keeps the tiles small. It also decouples the data lifecycle: attributes change without regenerating the pyramid. The trade-off is that the map no longer works offline for detail, and each click has a latency the embedded version does not.
Arrived ids, duplicates and escaping
- Include a feature id in the tiles. Deduplication and feature state both need it.
- Check that the ids actually arrived. GDAL 3.12's PMTiles output from a shapefile had none;
promoteIdon the source turns a unique property into the id. - Deduplicate hits; clipped features appear once per tile.
- Escape property values before
setHTML. Data is not trusted input. ["boolean", ["feature-state", ...], false]supplies the default for unset state.queryRenderedFeaturesrespects filters and zoom;querySourceFeaturesdoes not.- Never measure tile geometry β it is clipped and quantised.
- Touch devices need a larger hit area; query a small box rather than a point.
- Popups on mouse move need throttling or they fight the render loop.
Internal links
- Vector tiles explained: MVT, layers and why they are not GeoJSON β why clipping and quantisation happen
- How to style data-driven colours in a web map β expressions and feature state
- How to serve a web map from PMTiles with MapLibre β the map this attaches to
- My web map is blank or the layer never appears β the query API as a diagnostic
- How to publish a GeoJSON API from a GeoDataFrame with FastAPI β the detail endpoint
- How to build vector tiles from a GeoDataFrame in Python β including ids and attributes
- How to create interactive maps in Python with Folium β the simpler alternative
- How to calculate area and distance in GeoPandas (correctly) β why measurement belongs server-side
FAQ
How do I add a popup to a vector tile layer?
Listen for click on the layer id, take e.features[0], and set a popup's HTML from its properties β escaping the values first.
Why does one click return the same feature twice?
Because it crosses a tile boundary and is stored in both tiles. In MapLibre 4 a point click was answered from one tile, but an area query β a touch-sized box, or the whole viewport β returns both copies. Deduplicate by feature id.
How do I highlight a feature on hover?
map.setFeatureState with a hover key, and a paint expression reading ["feature-state", "hover"]. It needs a feature id in the tiles.
Why does setFeatureState highlight everything, or nothing?
The features have no id. MapLibre reports "The feature id parameter must be provided." on the map's error event, and here the state then appeared on every feature without an id. Write ids into the tiles, or name a unique property with promoteId.
Should I put all my attributes in the tiles?
No. Include an id and what the style needs; fetch the rest on click. Two short string fields added 25% to a measured tile.
Can I compute area from a clicked feature?
Not reliably. Tile geometry is clipped at tile edges and quantised to the tile grid. Ask a service that has the source geometry.
Why does my click handler miss features that are visible?
queryRenderedFeatures respects the layer's filter and zoom range. If the feature is drawn by a different layer, name that layer in the handler.