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.
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 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.
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