# Store finder: bring your own places

The worked recipe for the most common custom-data ask: *"we have N stores;
we want a store finder on MapMap"*. Everything here works today with a
gateway key; nothing requires a custom build. The moving parts:

| Need | What serves it |
|---|---|
| Pins, clustering, click-for-details on a web map | `PlacesLayer` in [`@mapmap/maps`](/docs/sdks) (client-side; your data never leaves the page) |
| "Search my stores" on the hosted API | [`PUT /places` + `GET /places/search`](/docs/api-reference#places-bring-your-own-places) (per-key dataset, ranked by the same engine as territory geocoding) |
| "Type a postcode, find my nearest store" | [`GET /geocode`](/docs/api-reference#geocoding) to resolve the postcode, then `nearest()` or `POST /matrix` |
| Nearest store, straight line | `PlacesLayer.nearest()` (client) or [`GET /places/nearest`](/docs/api-reference#places-bring-your-own-places) (hosted) |
| Nearest store, by drive time | `PlacesLayer.nearestByDriveTime()` → [`POST /matrix`](/docs/analysis#matrix-post-matrix) (one origin against N stores is one call) |
| Stores along a route, with real detour costs | [`POST /route/along`](/docs/analysis#along-route-search-post-routealong) |
| Directions / turn-by-turn to a store | `POST /route` hosted, or offline routing on-device (see [SDKs](/docs/sdks)) |

Your places are **your data**: on the web they render client-side and only
touch the gateway if you opt into hosted search or matrix ranking; on the
hosted API they are scoped to your key alone; in offline territory packages
they ship as their own signed layer, never mixed into OSM-derived tables
(the ODbL share-alike boundary, explained in [licensing](/docs/licensing)).

## Web: pins, clusters, popups in about 15 lines

`PlacesLayer` (full API in the
[add your own data guide](/docs/custom-data#points-placeslayer) and the
[`@mapmap/maps` README](https://www.npmjs.com/package/@mapmap/maps)) takes
an array of places or a GeoJSON `FeatureCollection` of Points:

```ts
import { createMap, PlacesLayer } from "@mapmap/maps";
import "maplibre-gl/dist/maplibre-gl.css";

const map = createMap({ container: "map", apiKey: "snk_…" });

const stores = new PlacesLayer(map, {
  places: storesJson, // [{id, name, lat, lon, properties?}, …] or GeoJSON
  cluster: true,
  fitBounds: true,
  popup: (p) => `<strong>${p.name}</strong><br>${p.properties?.hours ?? ""}`,
  onPlaceClick: (p) => showDetailPanel(p),
});

// Nearest three by straight line (pure client-side, no network):
stores.nearest({ lat: 51.5, lon: -0.13 }, 3);

// Nearest three by real drive time (one metered /matrix call):
await stores.nearestByDriveTime({ lat: 51.5, lon: -0.13 }, { n: 3 });
```

Clicking a cluster zooms to expand it; `setPlaces()` swaps the dataset in
place. `icon: { url }` replaces the default circle pin, and `icon` also
takes a record of images keyed by a feature property (`iconProperty`,
default `"kind"`), so a depot, a shop and a locker each draw differently;
places whose image has not loaded yet fall back to a circle. `label: true`
labels every unclustered place with its `name` (an options object picks a
different property, size and colours); labels carry a halo and hide before
they collide. For anything the helper does not cover, the raw MapLibre map
is one field away (`map.map`): the helper is convenience, not a wall.

### List-to-map sync

A store finder is usually a results list beside a map. `select(id)` opens
the configured popup at a place and eases the camera to it, returning the
`Place` (or `undefined` for an unknown id); `deselect()` closes the popup:

```ts
resultRow.addEventListener("click", () => {
  stores.select("man-01", { zoom: 13 });
});
```

`select` deliberately does not fire `onPlaceClick`: a programmatic
selection is not a user click.

## Postcode to nearest store

Resolve the postcode with the hosted geocoder, then rank:

```sh
export BASE=https://api.mapmap.ai
export API_KEY=snk_...

curl -fsS "$BASE/geocode?q=CR0+1PB&limit=1" \
  -H "Authorization: Bearer $API_KEY"
```

The response is a GeoJSON `FeatureCollection`; take the first feature's
coordinates and hand them to `stores.nearest(...)` (free, client-side) or
`stores.nearestByDriveTime(...)` (one `/matrix` call, real road-network
answer, truck costing available).

## Hosted search: "MyBrand Croydon" resolves to your store

Upload once, search from any client: the search box, the MCP tools, a
kiosk. Contracts and validation limits are in the
[API reference](/docs/api-reference#places-bring-your-own-places): up to
10,000 places per key, `properties` at most 2 KiB serialised per place,
atomic replace-and-reindex on every upload.

```sh
curl -fsS -X PUT "$BASE/places" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d @stores.json   # {"places": [{"id", "name", "lat", "lon", "locality", …}]}

curl -fsS "$BASE/places/search?q=MyBrand+Croydon&lat=51.5&lon=-0.12" \
  -H "Authorization: Bearer $API_KEY"
```

Ranking is full-text relevance with proximity decay, served by the same
engine as the territory geocode indexes, so brand + town queries behave the
way users expect. `properties` (hours, phone, URL) round-trip untouched, so
the search response is directly renderable. The dataset is scoped to your
key: no other key can read or search it.

Uploading also lights up
[`POST /route/along`](/docs/analysis#along-route-search-post-routealong):
"which of our stores is on my route, and what does the stop really cost?",
ranked by engine-measured detour time.

## Browser calls and CORS

The gateway answers with `Access-Control-Allow-Origin: *`, so browser calls
work from ordinary web apps and from `null`-origin contexts alike (Electron
apps, `file://` pages). Keys travel as bearer headers, never cookies, which
is what makes the open policy safe. Self-hosted deployments control this
with `SN_CORS_ORIGINS`.

## Mobile and offline

The Android and iOS SDKs are headless navigation cores: the host app owns
the MapLibre Native map view, so store pins are ordinary MapLibre
annotations over a JSON asset bundled with your app. That path is fully
offline and involves no MapMap service. "Navigate to this store" is
first-class: offline routing to the store's lat/lon, then the guidance
engine for turn-by-turn (see [SDKs](/docs/sdks)).

Signed offline [territory packages](/docs/territories) can also carry your
places as a separate signed layer with your attribution recorded in the
package manifest, built by the same factory that builds the map layers.
On-device *search* over that layer is not exposed in the mobile SDK APIs
yet, so for pin display a bundled JSON asset in the host app remains the
simplest path. Custom package builds are part of self-host and OEM
engagements: [contact us](/contact?topic=sales).

## Next steps

- [Add your own data](/docs/custom-data): the wider custom-data guide (choropleths, heatmaps, isochrones, static maps)
- [API reference](/docs/api-reference#places-bring-your-own-places): the five `/places` routes and their validation rules
- [Analysis APIs](/docs/analysis): `POST /matrix` for drive-time ranking, `POST /route/along` for along-route search
- [Quickstart](/docs/quickstart): issue a key in one call
