Skip to content
Documentation menu
docs / custom-data · raw .md

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 haveUseWhere it runs
Point data (stores, depots, assets, sensors)PlacesLayer in @mapmap/mapsClient-side; your data stays in the page
Any GeoJSON or PMTiles: lines, polygons, choropleths, clusters, heatmaps, extrusionsDataLayer in @mapmap/mapsClient-side
Any other MapLibre layer typePersistent layers at a slot, or map.map directlyClient-side
Clicks and hovers on the basemap's own POIs, place names and buildingsmap.addInteraction with a featuresetClient-side
A large dataset, tiled once and hosted on your own bucketPMTiles you hostClient-side, read straight from your storage
Imagery, a site plan or a videoRaster, image and video sourcesClient-side
A "how far can I get?" questionIsochroneLayer, computed by POST /isochroneGateway computes, SDK draws
Data to render into an image (a report, an email, an agent)The static map API with GeoJSON overlaysServer-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.

sh
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:

ts
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:

ts
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:

ts
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

ts
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.

ts
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. mode is "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.properties aggregates per cluster), "heatmap" or "extrusion" (extrusion.heightProperty, in metres).
  • Styling. style.categories (a colour per property value) wins over style.graduated ([value, colour] stops, strictly ascending), which wins over style.simple (colour, opacity, radius, width, outline).
  • Hover and selection are feature-state, on by default (hover: true, select: "single"; "multi" toggles, false turns it off). They need a stable id per feature: promoteId, or feature.id.
  • Updating. setData(next) replaces the data; updateData(diff) patches features by id (MapLibre's updateData, 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" with sourceLayer set to the layer name inside the archive.
  • Events. on("click" | "hover" | "select", handler) returns an unsubscribe function; selected(), select(ids), clearSelection() and remove() do what they say. ids names 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.

FeaturesetCoversFeature state
poipoi-labelsexact, one id per label
place-labelsplace-labels, city-labels, country-labelsexact
buildingsbuilding, building-3dbest effort: the tiles merge neighbouring footprints of the same height into one feature, so state lights up the whole merged group
ts
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:

ts
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

ts
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:

ts
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:

sh
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):

json
[
  {
    "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:

sh
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):

ts
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 full 200 body and the read fails.
  • Credit your data on the source. The attribution string 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):

ts
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:

ts
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.

ts
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) or feature.id. generateId: true works, but renumbers every feature on each setData, so any state you set is lost.
  • Paint only. ["feature-state", …] is readable in paint properties, never in layout or filter. Change which features draw with a filter or new data instead.
  • Vector tiles need the sourceLayer in every setFeatureState call, 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 addInteraction and 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).

SlotYour layer drawsInsert before this layer idIf that id is missing (a custom style)
bottomabove land, landuse and water; below roads and buildingsbuildingthe first layer whose id starts with road-, else on top
middleabove roads and 3D buildings; below every labelroad-onewaythe first symbol layer, else on top
topabove POI labels; below place, city and country labelsplace-labelscity-labels, else on top

Slots resolve against the live style, never by index, because a Studio theme can drop a layer:

ts
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 after onAdd can render nothing at all.
  • frustumCulled = false on every dynamic three.js mesh. The map's matrix breaks three.js culling, and meshes vanish as the camera moves.
  • Set customProgramCacheKey whenever you inject GLSL with onBeforeCompile, 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 returns 0, not null, until the elevation tiles have loaded.
  • Never call triggerRepaint() unconditionally from render(). 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.width and canvas.height per 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:

swift
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:

kotlin
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:

ts
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:

sh
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 /places accepts up to 10,000 point features per key, and each place's free-form properties object at most 2 KiB serialised. Over the count is a 413; a place failing per-field validation is a 422 with a problems list 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" or type: "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's SN_TILES_DIR and it is served at /tiles/your-data/{z}/{x}/{y}.mvt with no registration step, with the --attribution you recorded in its TileJSON and a minimal style.json that 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 (@2x available) 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/maps 0.7.0 or later) are a design-time feature for a fixed set of branded markers, not a data channel; use PlacesLayer for data.

Next steps