50,000 free calls a month, card-free. Get an API key →

Documentation menu
docs / custom-data · raw .md

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 haveUseWhere it runs
Point data (stores, depots, assets, sensors)PlacesLayer in @mapmap/mapsClient-side; your data stays in the page
Polygons, choropleths, heatmaps, anything elseThe MapLibre escape hatch: map.mapClient-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.

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

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.

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:

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

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

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

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 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 addSource examples above); a type: "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 (@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