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.

0 min 1 h 30 min or more Click a square for its route

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 * halfLat degrees 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