Skip to content

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.

Three numbers to plan around:

  • One request carries at most 10,000 elements (sources × targets). That is what finishes inside the request deadline; POST /v1/jobs/matrix takes 40,000 as a job you poll (see asynchronous jobs). 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.

    The gateway does its own chunking underneath. The routing engine takes at most 2,500 source×target pairs per call, so a request larger than that is split into blocks that fit, issued concurrently and stitched back into one grid: 2,000 × 4 and 100 × 100 are both four engine calls behind the one API call you made. Row and column order are preserved exactly, and if a block fails the whole request fails rather than returning a matrix with quietly wrong numbers in it.

  • A matrix has a maximum span, measured as the great-circle distance between the furthest origin and the furthest destination, and this one you cannot chunk around: the offending pair is in whichever block it lands in.

    The cap is the routing engine's service_limits.<costing>.max_matrix_distance, so it is set per costing, not per endpoint. On the hosted gateway:

    CostingMaximum span
    auto1,500 km
    truck1,500 km
    bus1,500 km
    taxi1,500 km
    motorcycle1,500 km
    motor_scooter1,500 km
    bicycle, pedestrian200 km

    Car and truck were raised from the engine's stock 400 km in August 2026; bus, taxi, motorcycle and scooter joined them in September 2026, so every motor costing now carries the same span. The walking and cycling limits were deliberately left where they are: a 1,500 km walking matrix is not a question anyone means to ask, and the limit is what stops one being computed.

    Over the motor-costing figure the gateway answers 422 matrix-span-too-large before dispatch, with span_km, max_span_km and furthest_pair: {source_index, target_index} in the body — the offending pair named by index into your own sources and targets, so you can drop or re-block it without bisecting the request yourself — plus limit_env, engine_setting and self_host_docs naming the two settings a self-hoster raises (engine first, gateway second). Note that the gateway's own pre-flight check carries one number rather than one per costing, and on the hosted deployment that number is the 1,500 km the motor costings share. So a cycling or walking matrix spanning between 200 and 1,500 km gets past the pre-flight check and is refused by the routing engine instead, which is a slower and less specific answer. Keep pedestrian and bicycle study areas inside 200 km and you will not meet it.

    Land's End to John o' Groats is about 968 km in a straight line, so a national Great Britain study on any motor costing now fits inside the cap where it did not before; a matrix from the Highlands to southern Spain still does not. Beyond it, either self-host and raise service_limits.<costing>.max_matrix_distance on your own deployment, or split the run by region with each region's own destinations.

  • 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). Note it prices elements, not engine calls, so the chunking above is not a discount and not a loophole: a 10,000-element matrix is 400 units whether the engine served it in one call or four. 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.

Limits, published

The same three numbers as a table, with where each one comes from, so a self-hosted deployment can be checked against its own configuration rather than against this page. Configurable means an environment variable on the gateway; fixed means compiled into the build.

LimitHosted valueSource of truth
Elements per request (sources x targets), synchronous10,000Fixed. 422 matrix-too-large, whose body carries max_pairs, sources, targets, pairs and an async_lane pointer.
Elements per request, asynchronous40,000Configurable: SN_MATRIX_MAX_ELEMENTS_ASYNC. POST /v1/jobs/matrix runs the identical pipeline as a job, at identical unit prices.
Billing block25 elements per unitFixed. Prices elements, not engine calls.
Engine matrix block size2,500 source x target pairsConfigurable: SN_ENGINE_MAX_MATRIX_PAIRS, at its default. Sets how a large request is chunked, not what it costs.
Span, every motor costing (auto, truck, bus, taxi, motorcycle, motor_scooter)1,500 kmConfigurable: SN_ENGINE_MAX_MATRIX_DISTANCE_M, set to 1500000 on the hosted gateway (verified live 14 Sept 2026, engine and gateway in step on every motor costing); the gateway default is 400,000 m. 422 matrix-span-too-large, naming the offending pair.
Span, bicycle and pedestrian200 kmThe engine's own service_limits.<costing>.max_matrix_distance, refused by the engine rather than the gateway pre-flight. See the note above.
MeteringNone when self-hostedThe Docker distro has no meter at all.

How that compares

For context, and checkable in both directions because their documentation is public. Quoted from the page linked beside each figure, checked on 1 September 2026.

MapMapMapbox
Coordinates per matrix request10,000 elements, so up to 100 x 100"Maximum 25 input coordinates per request" on driving, walking and cycling; "Maximum 10 input coordinates per request" on driving-traffic (Matrix API)
Truck and dangerous-goods costing on the matrixcosting: "truck" with dimensions, hazmat and adr_tunnel_code in costing_options.truckThe Matrix API documents no truck profile; the dimensional parameters max_height, max_width and max_weight are on Directions, and no ADR or hazmat parameter is documented anywhere (Directions API)
AvailabilityGenerally available on a self-serve keyGenerally available

Both surfaces are generally available, so the difference here is size and costing rather than access. The access difference is on optimisation.

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.

With only three destinations the script's chunkSize works out at 3,333 origins, which is over the 10,000-element cap only when the origin list is longer than that, so for the demonstration grid above every request is one chunk. Keep the loop anyway: it is what makes the script scale to an LSOA-sized origin list without changing a line, and it is the same shape the gateway uses internally one level down.

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