Documentation menu
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/matrixtakes 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:Costing Maximum 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-largebefore dispatch, withspan_km,max_span_kmandfurthest_pair: {source_index, target_index}in the body — the offending pair named by index into your ownsourcesandtargets, so you can drop or re-block it without bisecting the request yourself — pluslimit_env,engine_settingandself_host_docsnaming 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_distanceon 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.
| Limit | Hosted value | Source of truth |
|---|---|---|
Elements per request (sources x targets), synchronous | 10,000 | Fixed. 422 matrix-too-large, whose body carries max_pairs, sources, targets, pairs and an async_lane pointer. |
| Elements per request, asynchronous | 40,000 | Configurable: SN_MATRIX_MAX_ELEMENTS_ASYNC. POST /v1/jobs/matrix runs the identical pipeline as a job, at identical unit prices. |
| Billing block | 25 elements per unit | Fixed. Prices elements, not engine calls. |
| Engine matrix block size | 2,500 source x target pairs | Configurable: 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 km | Configurable: 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 pedestrian | 200 km | The engine's own service_limits.<costing>.max_matrix_distance, refused by the engine rather than the gateway pre-flight. See the note above. |
| Metering | None when self-hosted | The 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.
| MapMap | Mapbox | |
|---|---|---|
| Coordinates per matrix request | 10,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 matrix | costing: "truck" with dimensions, hazmat and adr_tunnel_code in costing_options.truck | The 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) |
| Availability | Generally available on a self-serve key | Generally 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 withminutes_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.
// 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
- Open kepler.gl/demo (runs entirely in the browser; your data stays local).
- Drag
od-matrix.csvin. kepler.gl detects the coordinate pairs and usually offers an arc layer immediately. - If it does not: add an Arc layer, set source to
origin_lat/origin_lonand target todest_lat/dest_lon. - 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
stepexpression onminutes_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
matrixtool, if you would rather ask an agent than write a script