# Origin-destination analysis: travel-time matrices for planners

One matrix call answers the questions that planners otherwise answer with
straight-line buffers: **which areas are within 15 minutes of a GP
surgery?** **Where should the next depot, clinic or charging hub go?**
**How many residents can actually reach the town centre by bike?**
[`POST /matrix`](/docs/analysis#matrix-post-matrix) computes real
road-network travel times from every origin to every destination in one
request, under any costing model (`auto`, `bicycle`, `pedestrian`,
`truck`), and the output drops straight into the tools planners already
use: a CSV for [kepler.gl](https://kepler.gl) arcs, or a GeoJSON for a
choropleth on a MapMap map.

This guide takes you from coordinates to both outputs with one
dependency-free Node script. If you are new to the matrix endpoint itself,
the [analysis guide](/docs/analysis#matrix-post-matrix) documents the
request shape and response format; this page is about using it for area
analysis at scale.

## The shape of the problem

An origin-destination (OD) matrix is `sources × targets` travel times.
For area analysis the origins are usually many (a grid over a study area,
LSOA population-weighted centroids, postcode centroids) and the
destinations few (GP surgeries, schools, a proposed site). The response is
row-major: `durations[i][j]` is seconds from `sources[i]` to
`targets[j]`, `null` where a pair is unreachable under the chosen costing.

Two numbers to plan around:

- **One request carries at most 10,000 elements** (`sources × targets`).
  A 100-origin grid against 3 destinations is 300 elements: one call.
  2,000 origins against 4 destinations is 8,000 elements: still one call.
  Beyond the cap, chunk the sources and merge, as the script below does.
- **Billing is per started block of 25 elements** on the hosted gateway
  (Standard class, Premium when the body carries `"costing": "truck"`; see
  the [shared conventions](/docs/analysis#shared-conventions) and
  [pricing](/pricing)). **A self-hosted deployment has no metering**: the
  [Docker distro](/docs/self-host) computes matrices as fast as your
  hardware allows, which is what makes county-scale OD analysis practical
  as a batch job.

## The script

Node 20 or later, no dependencies. It builds a demonstration grid of
origins over a study area, computes the matrix in cap-safe chunks, and
writes two files:

- `od-matrix.csv`: one row per origin-destination pair, ready for
  kepler.gl.
- `origins-nearest.geojson`: one point per origin with
  `minutes_to_nearest`, ready for the choropleth recipe in
  [Add your own data](/docs/custom-data#worked-example-a-choropleth-from-your-own-polygons).

Replace the grid and the destinations with your own coordinates (LSOA
centroids, site candidates) and it is a real analysis.

```js
// od-matrix.mjs. Run: MAPMAP_API_KEY=snk_... node od-matrix.mjs
const BASE = process.env.MAPMAP_BASE ?? "https://api.mapmap.ai";
const KEY = process.env.MAPMAP_API_KEY;
if (!KEY) throw new Error("Set MAPMAP_API_KEY");

const COSTING = "pedestrian"; // or "auto", "bicycle", "truck"
const MAX_ELEMENTS = 10_000; // gateway cap per request

// Demonstration origins: a 10 x 10 grid over central Brighton.
// Swap for your own list, e.g. LSOA population-weighted centroids.
const origins = [];
for (let row = 0; row < 10; row++) {
  for (let col = 0; col < 10; col++) {
    origins.push({
      lat: 50.815 + row * 0.004,
      lon: -0.17 + col * 0.006,
    });
  }
}

// Destinations: three GP surgeries.
const destinations = [
  { lat: 50.8289, lon: -0.1554 },
  { lat: 50.8391, lon: -0.1665 },
  { lat: 50.8226, lon: -0.1372 },
];

// Chunk sources so every request stays under the element cap.
const chunkSize = Math.max(1, Math.floor(MAX_ELEMENTS / destinations.length));
const chunks = [];
for (let i = 0; i < origins.length; i += chunkSize) {
  chunks.push(origins.slice(i, i + chunkSize));
}

const durations = []; // row-major, seconds, null = unreachable
const distances = []; // row-major, metres
for (const [index, sources] of chunks.entries()) {
  const res = await fetch(`${BASE}/matrix`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      sources,
      targets: destinations,
      costing: COSTING,
    }),
  });
  if (!res.ok) {
    throw new Error(`matrix chunk ${index + 1}: HTTP ${res.status} ${await res.text()}`);
  }
  const body = await res.json();
  durations.push(...body.durations);
  distances.push(...body.distances);
  console.log(`chunk ${index + 1}/${chunks.length} done`);
}

// 1. Flat CSV for kepler.gl (arc layer picks the lat/lng pairs up by name).
const rows = [
  "origin_lat,origin_lon,dest_lat,dest_lon,duration_min,distance_km",
];
origins.forEach((origin, i) => {
  destinations.forEach((dest, j) => {
    const seconds = durations[i][j];
    if (seconds === null) return; // unreachable under this costing
    rows.push(
      [
        origin.lat,
        origin.lon,
        dest.lat,
        dest.lon,
        (seconds / 60).toFixed(1),
        (distances[i][j] / 1000).toFixed(2),
      ].join(","),
    );
  });
});
await import("node:fs/promises").then((fs) =>
  fs.writeFile("od-matrix.csv", rows.join("\n")),
);

// 2. GeoJSON of origins coloured by time to the nearest destination.
const features = origins.map((origin, i) => {
  const reachable = durations[i].filter((s) => s !== null);
  return {
    type: "Feature",
    geometry: { type: "Point", coordinates: [origin.lon, origin.lat] },
    properties: {
      minutes_to_nearest:
        reachable.length === 0
          ? null
          : Number((Math.min(...reachable) / 60).toFixed(1)),
    },
  };
});
await import("node:fs/promises").then((fs) =>
  fs.writeFile(
    "origins-nearest.geojson",
    JSON.stringify({ type: "FeatureCollection", features }),
  ),
);

console.log(
  `Wrote ${rows.length - 1} pairs to od-matrix.csv and ${features.length} origins to origins-nearest.geojson`,
);
```

The chunks run sequentially on purpose: a county-scale run is a batch
job, not a latency race, and sequential requests are polite to a shared
gateway. On your own hardware, parallelise as far as your box allows.

## Loading the CSV into kepler.gl

1. Open [kepler.gl/demo](https://kepler.gl/demo) (runs entirely in the
   browser; your data stays local).
2. Drag `od-matrix.csv` in. kepler.gl detects the coordinate pairs and
   usually offers an arc layer immediately.
3. If it does not: add an **Arc** layer, set source to
   `origin_lat`/`origin_lon` and target to `dest_lat`/`dest_lon`.
4. Colour by `duration_min`, and filter on it to answer questions
   interactively ("show only pairs over 20 minutes").

The point of the pairing: kepler.gl is superb at rendering flows and
holds up to millions of rows, but it has no routing engine, so the
`duration_min` column has to come from somewhere. MapMap is that
somewhere, and on a self-hosted deployment the column costs nothing to
compute however large the study area.

## Colouring areas instead of drawing arcs

`origins-nearest.geojson` carries `minutes_to_nearest` per origin point.
Two ways to use it:

- In kepler.gl: drag the file in, use a **Point** or **Grid** layer
  coloured by `minutes_to_nearest`.
- On a MapMap map: join the values onto your area polygons and follow the
  [choropleth worked example](/docs/custom-data#worked-example-a-choropleth-from-your-own-polygons)
  in the custom data guide, using a `step` expression on
  `minutes_to_nearest`.

For reachability *contours* rather than per-origin values (the classic
"15-minute catchment" ring), one
[`POST /isochrone`](/docs/analysis#isochrones-post-isochrone) call per
destination is the better tool; the matrix is for when the origins
themselves are the unit of analysis.

## Next steps

- [Analysis APIs](/docs/analysis): the full matrix and isochrone contracts
- [Add your own data](/docs/custom-data): choropleths, heatmaps and clustered pins on a MapMap map
- [Self-hosting](/docs/self-host): unmetered matrices on your own hardware
- [MCP server](/docs/mcp): the `matrix` tool, if you would rather ask an agent than write a script
