# 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`](#points-placeslayer) in `@mapmap/maps` | Client-side; your data stays in the page |
| Polygons, choropleths, heatmaps, anything else | [The MapLibre escape hatch](#everything-else-the-maplibre-escape-hatch): `map.map` | Client-side |
| A "how far can I get?" question | [`IsochroneLayer`](#reachability-rings-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](#server-rendered-geojson-on-a-static-map-image) with GeoJSON overlays | Server-rendered PNG/WebP/JPEG |

Everything below works today with [`@mapmap/maps`](/docs/sdks) 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](#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](/docs#container-height).

## 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](/docs/analysis#matrix-post-matrix).

For the full store-finder recipe (list-to-map selection, hosted search,
postcode lookup), see the [store finder guide](/docs/store-finder).

## Everything else: the MapLibre escape hatch

`map.map` is the underlying [MapLibre GL JS](https://maplibre.org/) map.
The SDK does not wrap or restrict it: `addSource`, `addLayer`,
`setPaintProperty`, `queryRenderedFeatures` and the whole
[MapLibre style specification](https://maplibre.org/maplibre-style-spec/)
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](https://www.npmjs.com/package/@mapmap/maps));
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`](/docs/analysis#isochrones-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](#server-rendered-geojson-on-a-static-map-image).
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](/docs/analysis#shared-conventions).

## 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](https://github.com/mapbox/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](/docs/api-reference#static-map-images).

## 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`](/docs/analysis#along-route-search-post-routealong)
finds them along a route with honest detour costs. Contracts and validation
rules are in the
[API reference](/docs/api-reference#places-bring-your-own-places); the
worked recipe is the [store finder guide](/docs/store-finder).

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

- [Store finder guide](/docs/store-finder): the full recipe, from pins to hosted search to directions
- [Analysis APIs](/docs/analysis): isochrones, matrices and along-route search over your places
- [API reference](/docs/api-reference#places-bring-your-own-places): the `/places` contracts and validation rules
- [Maps, tiles & Studio](/docs/maps): the basemaps under your data
