Driving time from Portland
How long it takes to drive from Pioneer Courthouse Square, in downtown Portland, to the middle of four-mile squares in every direction, out to 40 miles. The freeways stretch the quick drives out in long arms, along I-5 north and south and I-84 up the Columbia River Gorge, while the Coast Range and the foothills of Mount Hood take far longer than their distance. Click a square for the route there.
Build it yourself
A Node.js script asks Mapricot's routing for the drive from downtown Portland to the middle of each square in a grid, and saves the squares as GeoJSON with their times. A page shades them, and asks for the route to a clicked square. It was tested with MapLibre GL JS 6.15 in Chromium and Node.js 22.
What you need
- A server key with the Routing product, for the script, made as Getting started shows. The script runs on your computer, so the key stays secret.
- A browser key with the Routing product, for the page. Add
http://localhost:*to its allowed origins, so it works from your computer while you build. - Node.js 18 or later. The map tiles need no key.
Collect the drive times
Make a folder for the map, and in it a file called drive-times.mjs:
import { writeFile } from "node:fs/promises";
const start = { lat: 45.5189, lon: -122.6793 };
const halfLat = (2 * 1609.344) / 111320;
const halfLon = halfLat / Math.cos((start.lat * Math.PI) / 180);
const features = [];
for (let y = -10; y <= 10; y++) {
for (let x = -10; x <= 10; x++) {
if (Math.hypot(x, y) > 10) continue;
const lat = Number((start.lat + y * 2 * halfLat).toFixed(5));
const lon = Number((start.lon + x * 2 * halfLon).toFixed(5));
const response = await fetch("https://api.mapricot.com/v1/route", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.MAPRICOT_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ waypoints: [start, { lat, lon }], mode: "drive" }),
});
const { routes } = await response.json();
if (!response.ok || !routes?.length) {
console.warn(`No route to ${lat}, ${lon}`);
continue;
}
const square = [
[lon - halfLon, lat - halfLat],
[lon + halfLon, lat - halfLat],
[lon + halfLon, lat + halfLat],
[lon - halfLon, lat + halfLat],
[lon - halfLon, lat - halfLat],
];
features.push({
type: "Feature",
properties: { lat, lon, minutes: Math.round(routes[0].durationSeconds / 60) },
geometry: { type: "Polygon", coordinates: [square] },
});
}
}
await writeFile("drive-times.geojson", JSON.stringify({ type: "FeatureCollection", features }));
console.log(`Saved ${features.length} drive times`);
- The grid's squares are four miles across,
2 * halfLatdegrees of latitude. A degree of longitude is shorter away from the equator, so the squares are wider in degrees of longitude, by one over the cosine of the latitude. - Only squares within ten steps of the middle are kept, so the grid makes a circle reaching 40 miles out.
- The API takes coordinates with up to six decimal places, so each point is rounded to five.
- A point with no road near it, deep in a forest, gets no route, and its square is left out.
Run it with your server key:
MAPRICOT_KEY=mapricot_sk_… node drive-times.mjs
It sends 317 requests, one at a time, in about a minute. Then it prints Saved 315 drive times, or close to it, and writes drive-times.geojson.
Make the page
Next to drive-times.geojson, make index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Driving time from Portland</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
</head>
<body style="margin: 0">
<div id="map" style="height: 100vh"></div>
<script type="module">
import * as maplibregl from "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm";
const key = "mapricot_pk_…";
const start = { lat: 45.5189, lon: -122.6793 };
const map = new maplibregl.Map({
container: "map",
style: "https://tiles.mapricot.com/styles/light.json",
center: [start.lon, start.lat],
zoom: 8.5,
});
await map.once("load");
const labels = map.getStyle().layers.find((layer) => layer.type === "symbol").id;
map.addSource("times", { type: "geojson", data: "drive-times.geojson" });
map.addLayer(
{
id: "times",
type: "fill",
source: "times",
paint: {
"fill-color": ["interpolate", ["linear"], ["get", "minutes"], 0, "#ffc078", 30, "#fd7e14", 60, "#c2410c", 90, "#7c2d12"],
"fill-opacity": 0.65,
"fill-outline-color": "#ffffff",
},
},
labels,
);
map.addSource("route", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
map.addLayer({ id: "route", type: "line", source: "route", paint: { "line-color": "#212529", "line-width": 4 } });
map.on("click", "times", async (event) => {
const { lat, lon } = event.features[0].properties;
const response = await fetch("https://api.mapricot.com/v1/route", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ waypoints: [start, { lat, lon }], mode: "drive" }),
});
const { routes, error } = await response.json();
if (!response.ok) return new maplibregl.Popup().setLngLat(event.lngLat).setText(error.message).addTo(map);
map.getSource("route").setData({ type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: decode(routes[0].polyline) } });
const minutes = Math.round(routes[0].durationSeconds / 60);
const miles = Math.round(routes[0].distanceMeters / 1609.344);
new maplibregl.Popup().setLngLat(event.lngLat).setText(`${minutes} min, ${miles} mi`).addTo(map);
});
// Routes come as polylines with six decimal places; MapLibre wants [lon, lat] pairs.
function decode(polyline) {
const points = [];
const position = [0, 0];
let index = 0;
while (index < polyline.length) {
for (const axis of [0, 1]) {
let shift = 0;
let value = 0;
let byte;
do {
byte = polyline.charCodeAt(index++) - 63;
value |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
position[axis] += value & 1 ? ~(value >> 1) : value >> 1;
}
points.push([position[1] / 1e6, position[0] / 1e6]);
}
return points;
}
</script>
</body>
</html>
Put your browser key in place of mapricot_pk_….
- The squares are shaded from light orange for the closest to brown at an hour and a half or more, and added under the style's labels so town names stay readable.
- A click reads the square's middle from its properties and asks for the route there, then draws it and shows its time and length.
- The times have no traffic in them, so the real drive at rush hour can be much longer.
Open it
Serve the folder with npx serve or python3 -m http.server, and open the address it prints. Opened straight from the file, the map stays blank and the API refuses the key, as Open the page explains. You should see a grid around Portland, lightest along the freeways. Click a square for the route there.
To draw the route with its stops and fit the map to it, see Draw a route with MapLibre GL JS.
Data
- Driving times and routes: Mapricot's routing, on OpenStreetMap's roads, with no traffic
- Map: OpenStreetMap contributors (ODbL), in Mapricot's map tiles