Documentation menu
Add your own data
MapMap renders your data as well as ours. This page covers the four 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 |
| Polygons, choropleths, heatmaps, anything else | The MapLibre escape hatch: map.map | 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.
npm install @mapmap/maps maplibre-gl pmtiles
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.
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.
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.map.addSource("wards", { type: "geojson", data: wards });
map.map.addLayer(
{
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,
},
},
"road-labels", // insert beneath the label layers so names stay readable
);
map.map.addLayer(
{
id: "wards-outline",
type: "line",
source: "wards",
paint: { "line-color": "#ffffff", "line-width": 0.5 },
},
"road-labels",
);
The second argument to addLayer is the existing layer to insert beneath.
The MapMap default styles carry a road-labels layer (the full 29-layer id
list is in the @mapmap/maps README);
on a custom style, pick your own insertion point.
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.map.addSource("incidents", { type: "geojson", data: incidents });
map.map.addLayer({
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,
},
});
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; layers you add through the escape hatch are yours to re-add:
map.map.on("style.load", () => {
// re-add your sources and layers here; guard with getSource/getLayer
});
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 tileset-upload service today. You cannot upload a large
dataset and have MapMap serve it back as vector tiles. Larger datasets
and polygon layers are loaded client-side from your own URL (the
addSourceexamples above); atype: "vector"MapLibre source pointing at your own tile server also works through the escape hatch. - 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