Documentation menu
Add your own data
MapMap renders your data as well as ours. This page covers the ways to put your own datasets on a MapMap map:
| You have | Use | Where it runs |
|---|---|---|
| Point data (stores, depots, assets, sensors) | PlacesLayer in @mapmap/maps | Client-side; your data stays in the page |
| Any GeoJSON or PMTiles: lines, polygons, choropleths, clusters, heatmaps, extrusions | DataLayer in @mapmap/maps | Client-side |
| Any other MapLibre layer type | Persistent layers at a slot, or map.map directly | Client-side |
| Clicks and hovers on the basemap's own POIs, place names and buildings | map.addInteraction with a featureset | Client-side |
| A large dataset, tiled once and hosted on your own bucket | PMTiles you host | Client-side, read straight from your storage |
| Imagery, a site plan or a video | Raster, image and video sources | Client-side |
| A "how far can I get?" question | IsochroneLayer, computed by POST /isochrone | Gateway computes, SDK draws |
| Data to render into an image (a report, an email, an agent) | The static map API with GeoJSON overlays | Server-rendered PNG/WebP/JPEG |
Everything below works today with @mapmap/maps and a gateway
key. Client-side layers render in the browser and never send your data to
the gateway; the gateway is only involved where a section says so (drive-time
ranking, isochrones, hosted search). The honest boundaries are collected in
Limits at the end, and runnable data-layer samples are in the
examples gallery.
npm install @mapmap/maps maplibre-gl pmtiles
Under Vite or Turbopack, also serve MapLibre's worker from your app, or the map stays blank: two files and one call.
Give the map container an explicit height first. MapLibre silently renders into a 0px-tall canvas when the container's height resolves to zero: no error, no map, no pins. See troubleshooting.
Points: PlacesLayer
PlacesLayer takes a plain GeoJSON FeatureCollection of Points (or an
array of {id, name, lat, lon, properties?} objects) and gives you
clustered pins with count badges, click-to-expand clusters, popups and
nearest-place answers:
import { createMap, PlacesLayer } from "@mapmap/maps";
import "maplibre-gl/dist/maplibre-gl.css";
const map = createMap({ container: "map", apiKey: "snk_…" });
await map.whenReady();
const stores = await (await fetch("/data/stores.geojson")).json();
// stores is a GeoJSON FeatureCollection of Point features
const layer = new PlacesLayer(map, {
places: stores,
cluster: true, // default: nearby points collapse into count badges
fitBounds: true, // fit the view to the data on first set
popup: (place) => `<strong>${place.name}</strong>`,
onPlaceClick: (place, lngLat) => console.log(place.id, lngLat),
});
Each feature's id comes from feature.id, then properties.id, then the
feature index; its name from properties.name. Clicking a cluster zooms
in to expand it. layer.setPlaces(next) replaces the dataset in place
(search-as-you-type just works: an update is never silently dropped), and
layer.clear() removes everything.
Data-driven pin colours
color accepts a MapLibre expression evaluated against each feature's
properties, so one dataset can draw per-category pins:
const layer = new PlacesLayer(map, {
places: stores,
color: [
"match", ["get", "category"],
"food", "#e63946",
"travel", "#457b9d",
"childcare", "#ffb703",
/* fallback */ "#3a86ff",
],
clusterColor: "#3a86ff",
});
The properties-flattening gotcha: everything in a place's properties
is copied verbatim onto the top level of its GeoJSON feature's properties
(the reserved id, name and __mapmapIndex keys win on collision), so a
scalar like category is directly ["get", …]-able. MapLibre
JSON-stringifies nested objects and arrays at render time, so keep anything
you want to style on as a top-level string, number or boolean, not
properties.meta.category.
A cluster mixes categories, so an expression never applies to cluster
circles: they use clusterColor, which defaults to color when that is a
plain string.
Custom pins and labels
icon: { url, size? } swaps the default circle for your own pin image
(falling back to the circle if the image fails to load). icon also takes
a record of images keyed by a feature property (iconProperty, default
"kind"), so a depot and a shop draw differently:
const layer = new PlacesLayer(map, {
places: stores,
icon: {
shop: { url: "/pins/shop.png", size: 0.8 },
depot: { url: "/pins/depot.png" },
},
iconProperty: "kind", // reads properties.kind on each place
label: true, // each unclustered place labelled with its name
});
Labels draw in the map font with a halo and hide before they collide; an
options object picks a different property, size and colours. For anything
the options do not reach, the generated MapLibre source and layer ids are
public API via layer.ids, so map.map.setPaintProperty(...) and
queryRenderedFeatures work on them directly.
Nearest place, straight line or by drive time
layer.nearest({ lat: 51.5, lon: -0.13 }, 3);
// pure client-side haversine, instant, adds distanceM to each place
await layer.nearestByDriveTime({ lat: 51.5, lon: -0.13 }, { n: 3, costing: "truck" });
// one gateway POST /matrix call (your origin against every place),
// sorted by durationS with the driven distanceM attached
nearestByDriveTime reuses the map's baseUrl and apiKey and bills as a
/matrix call.
For the full store-finder recipe (list-to-map selection, hosted search, postcode lookup), see the store finder guide.
Any GeoJSON or PMTiles: DataLayer
DataLayer puts any GeoJSON (inline or by URL) or your own PMTiles archive
on the map in one call: points, lines, polygons, clusters, a heatmap or 3D
extrusions, styled simply, by category or by a graduated number, with hover
and selection highlights, popups and a slot in the layer stack. Its source
and layers are persistent, so map.setStyle("dark") keeps them, in place.
import { createMap, DataLayer } from "@mapmap/maps";
const map = createMap({ container: "map", apiKey: "snk_…" });
await map.whenReady();
const depots = new DataLayer(map, {
data: "https://cdn.example.com/depots.geojson",
style: { categories: { property: "kind", values: { hub: "#0b7a75", spoke: "#c2410c" } } },
popup: (f) => {
const el = document.createElement("div");
el.textContent = String(f.properties?.name ?? ""); // text, never HTML from your data
return el;
},
slot: "middle", // the default: above roads, below labels
attribution: "(c) Example Ltd",
});
depots.on("select", (e) => console.log(e.selected));
- Modes.
modeis"auto"(default: by geometry; a mixed collection, a URL or tiles get fill, line and point layers, each filtered to its geometry),"point","line","polygon","cluster"(GeoJSON only;cluster.propertiesaggregates per cluster),"heatmap"or"extrusion"(extrusion.heightProperty, in metres). - Styling.
style.categories(a colour per property value) wins overstyle.graduated([value, colour]stops, strictly ascending), which wins overstyle.simple(colour, opacity, radius, width, outline). - Hover and selection are feature-state, on by default (
hover: true,select: "single";"multi"toggles,falseturns it off). They need a stable id per feature:promoteId, orfeature.id. - Updating.
setData(next)replaces the data;updateData(diff)patches features by id (MapLibre'supdateData, which needs a unique id on every feature from the start);setFilter(filter)narrows what draws. - Vector tiles.
data: "pmtiles://https://your-bucket.example.com/assets.pmtiles"withsourceLayerset to the layer name inside the archive. - Events.
on("click" | "hover" | "select", handler)returns an unsubscribe function;selected(),select(ids),clearSelection()andremove()do what they say.idsnames the generated source and layers, for anything the options do not reach.
Runnable versions: choropleth, clustered points, hover highlight, click for a popup and live updates.
Clicks on the basemap: addInteraction
map.addInteraction(id, { type, target, filter?, handler }) runs a handler
on click, mouseenter, mouseleave or mousemove over one layer
(target: { layerId }), a basemap featureset (target: { featuresetId })
or the map itself (no target). It is shaped like Mapbox GL JS
addInteraction; a handler returning true stops the event reaching
interactions further down, and later interactions get events first.
| Featureset | Covers | Feature state |
|---|---|---|
poi | poi-labels | exact, one id per label |
place-labels | place-labels, city-labels, country-labels | exact |
buildings | building, building-3d | best effort: the tiles merge neighbouring footprints of the same height into one feature, so state lights up the whole merged group |
map.addInteraction("poi-click", {
type: "click",
target: { featuresetId: "poi" },
handler: ({ feature }) => {
console.log(feature?.properties.name);
return true;
},
});
Targets are resolved against the live style on every event, so
interactions survive setStyle, and a featureset whose layers a theme
dropped matches nothing. map.setFeatureState({ featuresetId, id }, state)
sets state for paint expressions that read it. Runnable version:
click and hover on basemap features.
Everything else: the MapLibre escape hatch
map.map is the underlying MapLibre GL JS map.
The SDK does not wrap or restrict it: addSource, addLayer,
setPaintProperty, queryRenderedFeatures and the whole
MapLibre style specification
work over MapMap basemaps. If MapLibre can draw it, you can draw it here.
For layers that should stay through a theme swap, add them through the SDK
instead of map.map: map.addPersistentSource(id, source) and
map.addPersistentLayer(layer, { slot }) take the same MapLibre source and
layer objects and re-add them after every setStyle, in order, at their
slot.
Worked example: a choropleth from your own polygons
A local GeoJSON of polygons, coloured by a numeric property with a step
expression:
import { createMap } from "@mapmap/maps";
import "maplibre-gl/dist/maplibre-gl.css";
const map = createMap({
container: "map",
apiKey: "snk_…",
center: [-2.5, 54.0],
zoom: 6,
});
await map.whenReady();
const wards = await (await fetch("/data/wards.geojson")).json();
// polygons, each with a numeric properties.density
map.addPersistentSource("wards", { type: "geojson", data: wards });
map.addPersistentLayer(
{
id: "wards-fill",
type: "fill",
source: "wards",
paint: {
"fill-color": [
"step", ["get", "density"],
"#eff3ff", // below 50
50, "#bdd7e7",
200, "#6baed6",
1000, "#3182bd",
5000, "#08519c",
],
"fill-opacity": 0.65,
},
},
{ slot: "middle" }, // above the roads, beneath the labels so names stay readable
);
map.addPersistentLayer(
{
id: "wards-outline",
type: "line",
source: "wards",
paint: { "line-color": "#ffffff", "line-width": 0.5 },
},
{ slot: "middle" },
);
The slot names where in the layer stack your layer goes (see insertion points); it is resolved against the live style, so it works on a Studio theme too.
Worked example: a heatmap from your own points
const incidents = await (await fetch("/data/incidents.geojson")).json();
// Point features, each with a numeric properties.severity of 0 to 5
map.addPersistentSource("incidents", { type: "geojson", data: incidents });
map.addPersistentLayer({
id: "incidents-heat",
type: "heatmap",
source: "incidents",
maxzoom: 15,
paint: {
"heatmap-weight": [
"interpolate", ["linear"], ["get", "severity"], 0, 0, 5, 1,
],
"heatmap-intensity": [
"interpolate", ["linear"], ["zoom"], 6, 0.6, 14, 2,
],
"heatmap-radius": [
"interpolate", ["linear"], ["zoom"], 6, 8, 14, 24,
],
"heatmap-opacity": 0.8,
},
}, { slot: "middle" });
The one lifecycle rule
MapLibre wipes runtime sources and layers on every setStyle (a light/dark
theme swap, a Studio style change). The SDK's own layers re-install
themselves, and so do DataLayer and anything added with
addPersistentSource / addPersistentLayer (map.removePersistent(id)
takes one off; removing a source removes its layers). Layers you add
through map.map directly are yours to re-add:
map.map.on("style.load", () => {
// re-add your sources and layers here; guard with getSource/getLayer
});
Bring your own tiles (PMTiles you host)
For a dataset too large to load as one GeoJSON file (every asset a council owns, a national network, years of points), tile it once on your own machine and host the result as a single PMTiles file on your own storage. There is no tile server to run: the browser reads the byte ranges it needs straight from the file, so a multi-gigabyte archive draws as fast as a small one. Nothing is uploaded to MapMap and we store nothing.
1. Tile the data. Any PMTiles writer works. The self-host distro ships
one, snfactory tile:
snfactory tile assets.geojson -o assets.pmtiles \
--layer assets --maxzoom 14 \
--attribution "(c) Example Council 2026"
--layer is the source-layer your style will reference. --maxzoom 14
is usually plenty, because renderers overzoom past the deepest tiles.
2. Put it on a bucket that serves byte ranges. Amazon S3, Cloudflare
R2, Google Cloud Storage and Azure Blob Storage all do. Turn on CORS for
your site's origin, allowing the Range and If-Match request headers and
exposing ETag. The S3 form (R2 takes the same fields under lower-case
names):
[
{
"AllowedOrigins": ["https://your-site.example.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["range", "if-match"],
"ExposeHeaders": ["etag"],
"MaxAgeSeconds": 3000
}
]
On Google Cloud Storage the same rule is
[{"origin": ["https://your-site.example.com"], "method": ["GET", "HEAD"], "responseHeader": ["range", "etag", "if-match"], "maxAgeSeconds": 300}].
Without Range allowed, every read from a browser fails its CORS
preflight.
3. Check the bucket before you blame the map. A range read from a
browser origin must come back 206 with a Content-Range and the CORS
header. A 200 means the host ignored the range. Use GET with a Range
header, as here, because some hosts refuse HEAD:
curl -sS -D - -o /dev/null \
-H "Range: bytes=0-16383" \
-H "Origin: https://your-site.example.com" \
"https://your-bucket.example.com/assets.pmtiles"
4. Add it as a vector source. Put pmtiles:// in front of the https
URL. createMap registers the protocol on the maplibre-gl it imports;
call registerPmtilesProtocol yourself only for a maplibregl.Map you
construct directly, or a second copy of maplibre-gl in your bundle (it is
idempotent):
import { createMap, registerPmtilesProtocol } from "@mapmap/maps";
import * as maplibregl from "maplibre-gl";
registerPmtilesProtocol(maplibregl);
map.map.addSource("assets", {
type: "vector",
url: "pmtiles://https://your-bucket.example.com/assets.pmtiles",
attribution: "(c) Example Council 2026",
});
map.map.addLayer(
{
id: "assets-lines",
type: "line",
source: "assets",
"source-layer": "assets",
paint: { "line-color": "#e07b39", "line-width": 2.5 },
},
map.slot("middle"), // see "Where your layer goes" below
);
Two habits save a support ticket each:
- Publish a new version under a new file name (
assets-2026-10-12.pmtiles) rather than overwriting in place. A browser revalidating cached ranges against a changed file gets a full200body and the read fails. - Credit your data on the source. The
attributionstring joins the map credit while the layer is on the map. If the data derives from OpenStreetMap, the ODbL asks for that credit too.
The tiles.json alternative. If you already run a tile server (or a
self-hosted MapMap gateway, which serves any archive in SN_TILES_DIR at
/tiles/<id>/tiles.json), point the source at its TileJSON instead:
{ type: "vector", url: "https://tiles.example.com/assets/tiles.json" }.
MapLibre reads the tile template, zoom range, bounds and attribution from
it.
Runnable version: vector tiles you host as PMTiles.
Raster, image and video sources
Not everything is vectors. Three source types put pictures on the map, and all three need the files served with CORS headers, because MapLibre uploads them to WebGL.
XYZ raster tiles (aerial imagery, a flood model, a scanned map):
map.map.addSource("survey", {
type: "raster",
tiles: ["https://tiles.example.com/survey/{z}/{x}/{y}.png"],
tileSize: 256,
maxzoom: 17, // overzoom past your deepest tiles
bounds: [-2.05, 52.38, -1.73, 52.58], // no requests outside your coverage
attribution: "Survey imagery (c) Example Ltd 2026",
});
map.map.addLayer(
{ id: "survey", type: "raster", source: "survey", paint: { "raster-opacity": 0.8 } },
map.slot("middle"),
);
scheme: "tms" flips the y axis for servers that count from the south. A
TileJSON url works in place of tiles, as for vectors.
An image pinned to four corners (a site plan, a survey sheet, a
rendered surface). Corners are [lng, lat], clockwise from top left, and
need not form a rectangle in longitude and latitude, so an image on a
rotated national grid still lands exactly:
map.map.addSource("plan", {
type: "image",
url: "https://cdn.example.com/site-plan.png",
coordinates: [
[-0.1290, 51.5080], [-0.1250, 51.5080],
[-0.1250, 51.5055], [-0.1290, 51.5055],
],
});
map.map.addLayer({ id: "plan", type: "raster", source: "plan" }, map.slot("middle"));
// Later, a new picture or new corners without re-adding the layer:
map.map.getSource<ImageSource>("plan")?.updateImage({ url: "https://cdn.example.com/site-plan-v2.png" });
A video pinned to four corners, the same shape with urls (several
encodings; the browser plays the first it supports). MapLibre creates the
element muted and looping so it can autoplay; getSource<VideoSource>(id)
gives you pause(), play() and getVideo(). The file downloads in full,
so keep overlay loops short. Video sources are MapLibre GL JS only: MapLibre
Native has none, so give the apps a still image.
Runnable versions: your own XYZ raster tiles, image overlay and video overlay, the last two drawn with MapMap's own relief of Kynance Cove from Environment Agency LiDAR.
Hover and selection with feature-state
feature-state is a small, per-feature object you set at run time and read
in paint expressions. It is how hover and selection are done without
re-sending data: flipping a flag costs no setData and no re-tiling.
map.map.addSource("areas", {
type: "geojson",
data: "https://cdn.example.com/areas.geojson",
promoteId: "code", // a property unique to each feature
});
map.map.addLayer(
{
id: "areas-fill",
type: "fill",
source: "areas",
paint: {
"fill-color": [
"case",
["boolean", ["feature-state", "selected"], false], "#08519c",
["boolean", ["feature-state", "hover"], false], "#3182bd",
"#bdd7e7",
],
"fill-opacity": 0.6,
},
},
map.slot("middle"),
);
let hovered: string | number | undefined;
map.map.on("mousemove", "areas-fill", (e) => {
const id = e.features?.[0]?.id;
if (hovered !== undefined) map.map.setFeatureState({ source: "areas", id: hovered }, { hover: false });
hovered = id;
if (id !== undefined) map.map.setFeatureState({ source: "areas", id }, { hover: true });
});
map.map.on("mouseleave", "areas-fill", () => {
if (hovered !== undefined) map.map.setFeatureState({ source: "areas", id: hovered }, { hover: false });
hovered = undefined;
});
// Click to toggle selection.
map.map.on("click", "areas-fill", (e) => {
const id = e.features?.[0]?.id;
if (id === undefined) return;
const selected = map.map.getFeatureState({ source: "areas", id }).selected === true;
map.map.setFeatureState({ source: "areas", id }, { selected: !selected });
});
What to know before you rely on it:
- Ids must be unique and stable. On a GeoJSON source use
promoteId(a property) orfeature.id.generateId: trueworks, but renumbers every feature on eachsetData, so any state you set is lost. - Paint only.
["feature-state", …]is readable in paint properties, never inlayoutorfilter. Change which features draw with a filter or new data instead. - Vector tiles need the
sourceLayerin everysetFeatureStatecall, and the id has to be the same on every tile a feature crosses. Promote a property you control (promoteId: { assets: "asset_id" }). - Basemap ids are tile ids, not OpenStreetMap ids. MapMap basemap
features carry ids assigned when the tiles are built, the same for the
same feature in neighbouring tiles, so state works on them, but an id
never matches an OSM way. For the basemap's POIs, place names and
buildings use
addInteractionand its featuresets; buildings are best effort. removeFeatureState({ source })clears a whole source, for a "clear selection" button.
DataLayer does all of this for you (hover and select options); the
hand-written version above is for a layer you draw yourself. Runnable
version: hover highlight with feature-state.
Where your layer goes: insertion points
Where a layer sits in the stack decides whether your data draws under the
roads, over the roads, or over the labels. The MapMap styles share one
layer skeleton, so three slots, the same names as Mapbox Standard's, cover
almost every case. DataLayer, addPersistentLayer and the SDK's transit
layers take a slot; for a layer you add through map.map,
map.slot("middle") returns the layer id to pass as beforeId
(undefined means on top).
| Slot | Your layer draws | Insert before this layer id | If that id is missing (a custom style) |
|---|---|---|---|
bottom | above land, landuse and water; below roads and buildings | building | the first layer whose id starts with road-, else on top |
middle | above roads and 3D buildings; below every label | road-oneway | the first symbol layer, else on top |
top | above POI labels; below place, city and country labels | place-labels | city-labels, else on top |
Slots resolve against the live style, never by index, because a Studio theme can drop a layer:
map.addPersistentLayer(myLayer, { slot: "middle" }); // stays through setStyle
map.map.addLayer(otherLayer, map.slot("bottom")); // gone after the next setStyle
Hosted Studio styles take the same names: an extra_layers entry carries
"slot": "bottom" | "middle" | "top" (absent means middle), and a theme's
sources can point those layers at data you host yourself (see
hosted styles with your own sources). On MapLibre Native there is no slot helper:
insert below road-oneway for middle, as the iOS and Android samples
below do with road-labels (one layer higher, just above the one-way
arrows).
Custom WebGL layers
When no layer type fits (a particle field, a three.js scene, a shader of
your own), MapLibre runs your WebGL inside its frame through a custom
layer: an object with id, type: "custom", an optional
renderingMode ("2d", or "3d" to share the depth buffer with
extrusions and terrain), onAdd(map, gl) and render(gl, options). In
MapLibre 6, options carries modelViewProjectionMatrix,
defaultProjectionData and shaderData (a vertex prelude that makes the
same shader work on the flat map and the globe; cache compiled programs on
shaderData.variantName). @mapmap/maps draws its own animated route
effect this way.
The traps, every one of which has cost us a day:
- Build meshes synchronously in
onAdd. With three.js, meshes added to the scene afteronAddcan render nothing at all. frustumCulled = falseon every dynamic three.js mesh. The map's matrix breaks three.js culling, and meshes vanish as the camera moves.- Set
customProgramCacheKeywhenever you inject GLSL withonBeforeCompile, and change it whenever the injection changes, or three.js reuses the cached program without your code in it. - With terrain on, add the terrain elevation at the map centre when
you rebuild the camera (
map.map.queryTerrainElevation(center)), or sizes and fog compute from a camera underground. It returns0, notnull, until the elevation tiles have loaded. - Never call
triggerRepaint()unconditionally fromrender(). That is a repaint loop at the display's refresh rate; animate only while something is moving, and throttle it. - Follow the canvas size yourself. three.js reads the canvas size once
at construction; track
canvas.widthandcanvas.heightper frame or a resize draws into a stale rectangle.
MapLibre Native (iOS and Android)
On mobile there is no MapMap wrapper (see mobile): your data goes on with MapLibre Native's own runtime styling, once the style has loaded, and again after every style change, because a new style starts empty of your sources and layers.
iOS (Swift), clustered points. Inside
mapView(_:didFinishLoading:), which fires for every style load:
func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) {
let url = URL(string: "https://cdn.example.com/drops.geojson")!
let source = MLNShapeSource(identifier: "drops", url: url, options: [
.clustered: true,
.clusterRadius: 50,
])
style.addSource(source)
let clusters = MLNCircleStyleLayer(identifier: "drops-clusters", source: source)
clusters.predicate = NSPredicate(format: "cluster == YES")
clusters.circleColor = NSExpression(forConstantValue: UIColor.systemBlue)
clusters.circleRadius = NSExpression(forConstantValue: 16)
let points = MLNCircleStyleLayer(identifier: "drops-points", source: source)
points.predicate = NSPredicate(format: "cluster != YES")
points.circleColor = NSExpression(forConstantValue: UIColor.systemBlue)
points.circleRadius = NSExpression(forConstantValue: 5)
// Insert beneath the labels, the same insertion point as on the web.
if let labels = style.layer(withIdentifier: "road-labels") {
style.insertLayer(clusters, below: labels)
style.insertLayer(points, below: labels)
} else {
style.addLayer(clusters)
style.addLayer(points)
}
}
A heatmap is the same source without clustering and an
MLNHeatmapStyleLayer in place of the circle layers.
Android (Kotlin), clustered points and a heatmap. Inside the
setStyle callback, which you pass again on every style change:
import org.maplibre.android.style.layers.CircleLayer
import org.maplibre.android.style.layers.HeatmapLayer
import org.maplibre.android.style.layers.PropertyFactory.*
import org.maplibre.android.style.expressions.Expression.*
import org.maplibre.android.style.sources.GeoJsonOptions
import org.maplibre.android.style.sources.GeoJsonSource
import java.net.URI
map.setStyle(Style.Builder().fromUri(styleUri)) { style ->
style.addSource(
GeoJsonSource(
"drops",
URI("https://cdn.example.com/drops.geojson"),
GeoJsonOptions().withCluster(true).withClusterRadius(50),
)
)
style.addLayerBelow(
CircleLayer("drops-clusters", "drops")
.withFilter(has("point_count"))
.withProperties(circleColor("#1a6bff"), circleRadius(16f)),
"road-labels",
)
style.addLayerBelow(
CircleLayer("drops-points", "drops")
.withFilter(not(has("point_count")))
.withProperties(circleColor("#1a6bff"), circleRadius(5f)),
"road-labels",
)
// A heatmap over a second, unclustered source.
style.addSource(GeoJsonSource("reports", URI("https://cdn.example.com/reports.geojson")))
style.addLayerBelow(HeatmapLayer("reports-heat", "reports"), "road-labels")
}
GeoJsonOptions().withClusterProperty(...) is the native form of
clusterProperties.
PMTiles you host, on Android. MapLibre Native Android reads
pmtiles:// itself from 11.8.0, so the bucket from
Bring your own tiles works as is:
style.addSource(VectorSource("assets", "pmtiles://https://your-bucket.example.com/assets.pmtiles")),
then layers with .withSourceLayer("assets"). MapLibre Native iOS has read
pmtiles:// since 6.10.0.
feature-state on native. Android has it from 13.4.0
(GeoJsonSource.setFeatureState(featureId, state) and the
featureState(...) expression), the version our own Android modules build
against. iOS gained a feature-state API in 6.30.0
(MLNShapeSource.setFeatureState(featureID:state:)); we have not exercised
it in a MapMap app yet, so test it on your target version before you build
selection on it. On older releases, restyle by filter or by replacing the
source data instead.
Reachability rings: IsochroneLayer
"What can I reach in 5, 10, 15 minutes?" as one call. The gateway computes
the contours (POST /isochrone,
including truck costing with dimensional and ADR constraints) and the layer
renders graduated-opacity fills, contour outlines and "N min" labels:
import { IsochroneLayer } from "@mapmap/maps";
const rings = new IsochroneLayer(map); // baseUrl and apiKey come from the map
await rings.showReachability({
origin: { lat: 51.5074, lon: -0.1278 },
mode: "walk", // "walk" | "cycle" | "drive" | "truck" | any raw costing
minutes: [5, 10, 15],
});
// later: rings.clear();
showReachability resolves to the raw GeoJSON FeatureCollection (each
feature carries a contour property in minutes), so you can also feed the
shapes into your own analysis or a static map render.
The rings survive theme swaps. costingOptions forwards engine costing
options verbatim, e.g. { pedestrian: { use_lit: 1.0 } }.
Billing: an isochrone bills one call per contour (minutes: [5, 10, 15] is
three), Standard class, or Premium with mode: "truck". See
analysis.
Server-rendered: GeoJSON on a static map image
No browser at all: GET/POST https://mapmap.ai/api/static-map renders
your GeoJSON onto a basemap server-side and returns a PNG, WebP or JPEG.
Overlays honour the
simplestyle-spec stroke,
stroke-width, stroke-opacity, fill and fill-opacity properties on
each feature, and the camera auto-fits to the overlay when you omit
center and bbox:
curl -X POST "https://mapmap.ai/api/static-map?size=800x500" \
-H "Content-Type: application/json" \
-d '{
"type": "Feature",
"properties": { "stroke": "#1a6bff", "fill": "#1a6bff", "fill-opacity": 0.25 },
"geometry": {
"type": "Polygon",
"coordinates": [[[-0.14, 51.5], [-0.1, 51.5], [-0.1, 51.53], [-0.14, 51.53], [-0.14, 51.5]]]
}
}' -o zone.png
This is a website endpoint (mapmap.ai, not api.mapmap.ai), takes no API
key, and is rate-limited instead. Caps: at most 20 features and 50 markers
per image, images up to 1280 by 1280 pixels (append @2x for double pixel
density), GeoJSON payloads up to 512 KB in a POST body (8 KB in a query
string), and route= polylines thinned beyond 4,000 points. The full
parameter table (routes, markers, styles, satellite, 3D buildings) is in
the API reference.
Hosted places: search over your data
Optionally, upload your point dataset once to the gateway and every client
(a search box, the MCP tools, a kiosk) can search it: PUT /places stores
up to 10,000 places scoped to your key alone, GET /places/search ranks
them with the same engine that powers territory geocoding, and
POST /route/along
finds them along a route with honest detour costs. Contracts and validation
rules are in the
API reference; the
worked recipe is the store finder guide.
Limits
Stated plainly, so you can plan around them:
PUT /placesaccepts up to 10,000 point features per key, and each place's free-formpropertiesobject at most 2 KiB serialised. Over the count is a413; a place failing per-field validation is a422with aproblemslist naming each offender. Points only: the hosted places store does not take polygons or lines.- There is no hosted tileset upload. You cannot send a dataset to the
hosted API and have it served back as tiles, and that is a decision, not
a gap on a roadmap: we do not hold customer datasets. Large datasets go
on the map as PMTiles you host
on your own bucket, or from your own tile server through a
type: "vector"ortype: "raster"source. - Tiling runs on your machine. The local tiler is
snfactory tile(snfactory tile your-data.geojson -o your-data.pmtiles), in the self-host distro today, or, with no install beyond Node 18,npx @mapmap/tile your-data.geojson -o your-data.pmtiles --layer your-data(the same tiler compiled to WebAssembly, also importable in the browser; see@mapmap/tile); any other PMTiles writer works too. Self-hosters can also drop the archive into the gateway'sSN_TILES_DIRand it is served at/tiles/your-data/{z}/{x}/{y}.mvtwith no registration step, with the--attributionyou recorded in its TileJSON and a minimalstyle.jsonthat draws it. See tile your own data. Nothing is uploaded to us either way. - Video sources and custom WebGL layers are web only. MapLibre Native on iOS and Android has no video source, and its custom layers are a different, platform-specific API.
- Client-side layers load the data in full into the page. MapLibre's native clustering keeps large point sets responsive, but the download and memory cost is yours; there is no server-side simplification.
- Static map images are capped at 20 overlay features, 50 markers,
1280 by 1280 pixels (
@2xavailable) and 512 KB of GeoJSON per POST body;route=polylines are thinned beyond 4,000 points. - Markers designed in Studio (
MarkersLayer, up to 200 markers travelling with the theme, requires@mapmap/maps0.7.0 or later) are a design-time feature for a fixed set of branded markers, not a data channel; usePlacesLayerfor data.
Next steps
- Store finder guide: the full recipe, from pins to hosted search to directions
- Analysis APIs: isochrones, matrices and along-route search over your places
- API reference: the
/placescontracts and validation rules - Maps, tiles & Studio: the basemaps under your data