10-minute walk to a park map with MapLibre GL JS

A 10-minute walk, about half a mile, is the yardstick park planners use for whether a park is close to home. This map shows everywhere in Portland within a 10-minute walk of one of its parks and natural areas. The parks are dark green and the walks to them light green, on Mapricot's default map.

The biggest parks are named, and the rest show their names as you zoom in or point at them. Click anywhere for the 10-minute walk from there, and the parks it reaches.

Park Within a 10-minute walk Further than that

Most of the rest is industrial land along the Columbia and the Willamette and around the airport, where few people live. The largest gaps in neighborhoods are in East Portland, east of I-205.

The parks are the open parks and natural areas in Metro's RLIS that people can walk in, in Portland and just outside it. Trails, greenways, parkways, and corridors, which are long strips beside a path or a road, are left out. The walks are measured from the edge of each park, not its middle, since a big park like Forest Park is miles across.

Mapricot's isochrone API returns the area you can reach from one starting point, so the edges of the parks have a point about every 250 m, 2,425 in all. The light green is the 10-minute walks from all of them, merged into one.

Build it yourself

The page below asks Metro for the parks in inner Southeast Portland, puts a point every 250 m around each park's edge, asks for the 10-minute walk from each point, and merges the walks into one area with Turf. It's the map above for a few neighborhoods, without the colors or the clicks. It was tested with MapLibre GL JS 6.15 and Turf 7.3 in Chromium.

What you need

A web server to serve the page, and a browser key with the Routing and Map tiles products, made as API keys shows. Add http://localhost:* to its allowed origins, so it works from your computer while you build. Signing up is free, with no credit card, and includes 250,000 requests every 30 days. Map tiles don't count toward them. The page sends about 120 isochrone requests each time it loads.

Make the page

Make a folder, and in it index.html, with your browser key in place of both mapricot_pk_…:

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>A 10-minute walk to a park</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
    <script type="importmap">
      {
        "imports": {
          "maplibre-gl": "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm",
          "mapricot": "https://cdn.jsdelivr.net/npm/mapricot@0.17.0/+esm",
          "@turf/turf": "https://cdn.jsdelivr.net/npm/@turf/turf@7.3.5/+esm"
        }
      }
    </script>
  </head>
  <body style="margin: 0">
    <div id="map" style="height: 100vh"></div>
    <script type="module">
      import * as maplibregl from "maplibre-gl";
      import { Mapricot } from "mapricot";
      import { featureCollection, flatten, lineChunk, polygonToLine, union } from "@turf/turf";

      const client = new Mapricot({ apiKey: "mapricot_pk_…" });
      const box = [-122.665, 45.5, -122.6, 45.53];

      const map = new maplibregl.Map({
        container: "map",
        style: "https://api.mapricot.com/tiles/v1/styles/default.json?key=mapricot_pk_…",
        bounds: [box.slice(0, 2), box.slice(2)],
      });
      await map.once("load");

      const query = new URLSearchParams({
        where: "UNITTYPE IN ('Park', 'Natural Area') AND RECREATION IN ('Yes', 'Some') AND STATUS = 'Open'",
        geometry: box.join(","),
        geometryType: "esriGeometryEnvelope",
        inSR: 4326,
        outSR: 4326,
        f: "geojson",
      });
      const response = await fetch(`https://services2.arcgis.com/McQ0OlIABe29rJJy/ArcGIS/rest/services/Outdoor_Recreation_and_Conservation_Areas_ORCA/FeatureServer/5/query?${query}`);
      const parks = await response.json();

      const starts = parks.features.flatMap((park) =>
        flatten(polygonToLine(park)).features.flatMap((edge) => lineChunk(edge, 0.25).features.map((chunk) => chunk.geometry.coordinates[0])),
      );

      const walks = [];
      for (let i = 0; i < starts.length; i += 4) {
        const batch = starts.slice(i, i + 4).map(([lon, lat]) => client.routing.isochrone({ location: { lat, lon }, minutes: [10], mode: "walk" }));
        for (const { features } of await Promise.all(batch)) walks.push(...features);
      }
      const walk = union(featureCollection(walks));

      map.addSource("walk", { type: "geojson", data: walk });
      map.addLayer({ id: "walk", type: "fill", source: "walk", paint: { "fill-color": "#79d8a2" } }, "water");
      map.addSource("parks", { type: "geojson", data: parks });
      map.addLayer({ id: "parks", type: "fill", source: "parks", paint: { "fill-color": "#248f52" } }, "water");
    </script>
  </body>
</html>
  • Metro's parks layer is an ArcGIS feature service, which answers requests from any website. query returns the parks that touch box as GeoJSON, in longitude and latitude with outSR=4326 and f=geojson. The where clause keeps the open parks and natural areas people can walk in, as the map above does.
  • polygonToLine turns each park into the line around its edge, flatten splits a park in several pieces into one line each, and lineChunk cuts each line into stretches of 250 m. The first point of each stretch is a starting point.
  • client.routing.isochrone comes from Mapricot's JavaScript client, loaded from jsDelivr like MapLibre. It takes one starting point, so the page asks for each point's walk, four at a time, and keeps each walk's area. Each one counts as a request.
  • union merges the walks into one area, which covers everywhere at least one park's edge reaches in 10 minutes.
  • The walk and the parks go in under the style's water, so the river and the street names stay on top.

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 the parks of inner Southeast Portland in dark green, and after about 15 seconds the area within a 10-minute walk of them in a lighter green. Everywhere left uncolored is further than that.

To map another city, change box and swap Metro's parks for your own, such as OpenStreetMap's leisure=park areas. A bigger area takes more requests, and the map above took 2,425.

Data