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

Documentation menu
docs / od-matrix · raw .md

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 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 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 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 and pricing). A self-hosted deployment has no metering: the Docker distro 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.

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 (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 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 call per destination is the better tool; the matrix is for when the origins themselves are the unit of analysis.

Next steps

  • Analysis APIs: the full matrix and isochrone contracts
  • Add your own data: choropleths, heatmaps and clustered pins on a MapMap map
  • Self-hosting: unmetered matrices on your own hardware
  • MCP server: the matrix tool, if you would rather ask an agent than write a script