Skip to content

Find Locations Inside an Isochrone in MapLibre GL JS

Drawing a catchment area is half the job. The question behind it is almost always which of your own locations are inside it: which branches serve this address, which stops a courier can reach, which listings to show first. This example fetches a 15 minute walking isochrone from the Maptoolkit Isochrone API for a draggable origin, tests ten places against it with a point-in-polygon check, and colors and lists the reachable ones in MapLibre GL JS.

const API_KEY = "YOUR_API_KEY";
    const MINUTES = 15;

    // Stand-ins for your own data: anything with a coordinate works.
    const locations = {
      type: "FeatureCollection",
      features: [
        ["Stephansplatz", 16.3725, 48.2085], ["Karlsplatz", 16.3700, 48.2005],
        ["Rathaus", 16.3573, 48.2108], ["Praterstern", 16.3920, 48.2183],
        ["Westbahnhof", 16.3380, 48.1968], ["Schwedenplatz", 16.3789, 48.2118],
        ["Belvedere", 16.3806, 48.1915], ["Augarten", 16.3760, 48.2265],
        ["Naschmarkt", 16.3631, 48.1985], ["Hauptbahnhof", 16.3760, 48.1856],
      ].map(([name, lng, lat]) => ({
        type: "Feature",
        properties: { name, inside: false },
        geometry: { type: "Point", coordinates: [lng, lat] },
      })),
    };

    const map = new maplibregl.Map({
      container: "map",
      style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
      center: [16.3722, 48.2032],
      zoom: 13,
      attributionControl: { compact: false },
    });
    map.addControl(new maplibregl.NavigationControl(), "top-right");

    // Ray casting: a ray from the point to the right crosses the ring an odd number of times if the point is inside.
    function inRing([x, y], ring) {
      let inside = false;
      for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
        const [xi, yi] = ring[i];
        const [xj, yj] = ring[j];
        if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
      }
      return inside;
    }

    // A polygon is an outer ring followed by holes: inside the outer ring, outside every hole.
    function inPolygon(point, [outer, ...holes]) {
      return inRing(point, outer) && !holes.some((hole) => inRing(point, hole));
    }

    function inGeometry(point, geometry) {
      if (geometry.type === "Polygon") return inPolygon(point, geometry.coordinates);
      if (geometry.type === "MultiPolygon") return geometry.coordinates.some((polygon) => inPolygon(point, polygon));
      return false;
    }

    const origin = new maplibregl.Marker({ color: "#303f7e", draggable: true }).setLngLat([16.3722, 48.2082]);

    async function update() {
      const { lng, lat } = origin.getLngLat();
      const url = new URL("https://routing.maptoolkit.net/isochrone");
      url.searchParams.set("point", `${lat},${lng}`);
      url.searchParams.set("time", MINUTES);
      url.searchParams.set("routeType", "foot");
      url.searchParams.set("format", "geojson");
      url.searchParams.set("api_key", API_KEY);

      const area = await (await fetch(url)).json();
      map.getSource("area").setData(area);

      for (const feature of locations.features) {
        feature.properties.inside = inGeometry(feature.geometry.coordinates, area.geometry);
      }
      map.getSource("locations").setData(locations);

      const reachable = locations.features.filter((feature) => feature.properties.inside);
      document.getElementById("panel").innerHTML =
        `<strong>${reachable.length} of ${locations.features.length} within ${MINUTES} min on foot</strong>` +
        reachable.map((feature) => feature.properties.name).join(", ") +
        '<div class="hint">Drag the pin to move the origin.</div>';
    }

    map.once("style.load", () => {
      const empty = { type: "FeatureCollection", features: [] };
      map.addSource("area", { type: "geojson", data: empty });
      map.addSource("locations", { type: "geojson", data: locations });

      const firstSymbolId = map.getStyle().layers.find((layer) => layer.type === "symbol")?.id;
      map.addLayer({ id: "area-fill", type: "fill", source: "area", paint: { "fill-color": "#2171b5", "fill-opacity": 0.18 } }, firstSymbolId);
      map.addLayer({ id: "area-line", type: "line", source: "area", paint: { "line-color": "#2171b5", "line-width": 2 } }, firstSymbolId);
      map.addLayer({
        id: "locations", type: "circle", source: "locations",
        paint: {
          "circle-radius": 7,
          "circle-color": ["case", ["get", "inside"], "#2171b5", "#adb5bd"],
          "circle-stroke-color": "#fff",
          "circle-stroke-width": 2,
        },
      });
      map.addLayer({
        id: "locations-label", type: "symbol", source: "locations",
        layout: { "text-field": ["get", "name"], "text-font": ["Roboto Regular"], "text-size": 12, "text-offset": [0, 1.1], "text-anchor": "top" },
        paint: { "text-color": "#1f2430", "text-halo-color": "#fff", "text-halo-width": 1.5 },
      });

      origin.addTo(map).on("dragend", update);
      update();
    });
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@5.6.1/dist/maplibre-gl.css" />
  <script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.6.1/dist/maplibre-gl.js"></script>
  <style>
    html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
    #map { width: 100%; height: 100%; }
    #panel {
      position: absolute; top: 12px; left: 12px; z-index: 1; width: 220px; padding: 10px 12px;
      background: #fff; border-radius: 10px; box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18);
      font: 13px/1.5 system-ui, sans-serif; color: #1f2430;
    }
    #panel strong { display: block; margin-bottom: 2px; }
    #panel .hint { margin-top: 6px; color: #6b7185; font-size: 12px; }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="panel">Loading...</div>
  <script>
    const API_KEY = "YOUR_API_KEY";
    const MINUTES = 15;

    // Stand-ins for your own data: anything with a coordinate works.
    const locations = {
      type: "FeatureCollection",
      features: [
        ["Stephansplatz", 16.3725, 48.2085], ["Karlsplatz", 16.3700, 48.2005],
        ["Rathaus", 16.3573, 48.2108], ["Praterstern", 16.3920, 48.2183],
        ["Westbahnhof", 16.3380, 48.1968], ["Schwedenplatz", 16.3789, 48.2118],
        ["Belvedere", 16.3806, 48.1915], ["Augarten", 16.3760, 48.2265],
        ["Naschmarkt", 16.3631, 48.1985], ["Hauptbahnhof", 16.3760, 48.1856],
      ].map(([name, lng, lat]) => ({
        type: "Feature",
        properties: { name, inside: false },
        geometry: { type: "Point", coordinates: [lng, lat] },
      })),
    };

    const map = new maplibregl.Map({
      container: "map",
      style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
      center: [16.3722, 48.2032],
      zoom: 13,
      attributionControl: { compact: false },
    });
    map.addControl(new maplibregl.NavigationControl(), "top-right");

    // Ray casting: a ray from the point to the right crosses the ring an odd number of times if the point is inside.
    function inRing([x, y], ring) {
      let inside = false;
      for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
        const [xi, yi] = ring[i];
        const [xj, yj] = ring[j];
        if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
      }
      return inside;
    }

    // A polygon is an outer ring followed by holes: inside the outer ring, outside every hole.
    function inPolygon(point, [outer, ...holes]) {
      return inRing(point, outer) && !holes.some((hole) => inRing(point, hole));
    }

    function inGeometry(point, geometry) {
      if (geometry.type === "Polygon") return inPolygon(point, geometry.coordinates);
      if (geometry.type === "MultiPolygon") return geometry.coordinates.some((polygon) => inPolygon(point, polygon));
      return false;
    }

    const origin = new maplibregl.Marker({ color: "#303f7e", draggable: true }).setLngLat([16.3722, 48.2082]);

    async function update() {
      const { lng, lat } = origin.getLngLat();
      const url = new URL("https://routing.maptoolkit.net/isochrone");
      url.searchParams.set("point", `${lat},${lng}`);
      url.searchParams.set("time", MINUTES);
      url.searchParams.set("routeType", "foot");
      url.searchParams.set("format", "geojson");
      url.searchParams.set("api_key", API_KEY);

      const area = await (await fetch(url)).json();
      map.getSource("area").setData(area);

      for (const feature of locations.features) {
        feature.properties.inside = inGeometry(feature.geometry.coordinates, area.geometry);
      }
      map.getSource("locations").setData(locations);

      const reachable = locations.features.filter((feature) => feature.properties.inside);
      document.getElementById("panel").innerHTML =
        `<strong>${reachable.length} of ${locations.features.length} within ${MINUTES} min on foot</strong>` +
        reachable.map((feature) => feature.properties.name).join(", ") +
        '<div class="hint">Drag the pin to move the origin.</div>';
    }

    map.once("style.load", () => {
      const empty = { type: "FeatureCollection", features: [] };
      map.addSource("area", { type: "geojson", data: empty });
      map.addSource("locations", { type: "geojson", data: locations });

      const firstSymbolId = map.getStyle().layers.find((layer) => layer.type === "symbol")?.id;
      map.addLayer({ id: "area-fill", type: "fill", source: "area", paint: { "fill-color": "#2171b5", "fill-opacity": 0.18 } }, firstSymbolId);
      map.addLayer({ id: "area-line", type: "line", source: "area", paint: { "line-color": "#2171b5", "line-width": 2 } }, firstSymbolId);
      map.addLayer({
        id: "locations", type: "circle", source: "locations",
        paint: {
          "circle-radius": 7,
          "circle-color": ["case", ["get", "inside"], "#2171b5", "#adb5bd"],
          "circle-stroke-color": "#fff",
          "circle-stroke-width": 2,
        },
      });
      map.addLayer({
        id: "locations-label", type: "symbol", source: "locations",
        layout: { "text-field": ["get", "name"], "text-font": ["Roboto Regular"], "text-size": 12, "text-offset": [0, 1.1], "text-anchor": "top" },
        paint: { "text-color": "#1f2430", "text-halo-color": "#fff", "text-halo-width": 1.5 },
      });

      origin.addTo(map).on("dragend", update);
      update();
    });
  </script>
</body>
</html>

Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.

Use the Maptoolkit Connector. Create a MapLibre GL JS map of Vienna with a draggable origin marker and ten of my own locations as a circle layer with labels. Request a 15 minute walking isochrone from the origin with the Maptoolkit Isochrone API, color each location by whether it falls inside the polygon, list the reachable ones in a panel, and recalculate when the origin is dragged.

How it works

The Isochrone API returns the reachable area as a GeoJSON polygon. Which of your locations fall inside it is a geometry test you run on that response, and for points it is short enough to write out.

Ray casting. inRing follows a ray from the point to the right and counts how often it crosses the ring’s edges. An odd count means inside. The check (yi > y) !== (yj > y) counts each edge once, even when the ray passes exactly through a vertex.

Holes and pieces. A GeoJSON Polygon is a list of rings: the outer boundary first, then the holes. Isochrones have real holes, such as a park without paths or a block enclosed by a motorway, which are unreachable even though they lie inside the outer boundary. inPolygon excludes them. A MultiPolygon can come back too, when water splits the area into pieces.

One source update per answer. The result is written into each feature’s inside property, and setData() sends the updated collection to the map. The circle layer’s case expression picks the color from that property, so there is no marker per location to keep in sync.

Coordinates. The request’s point is lat,lng. Everything else, the response and the map, uses [lng, lat], so the test needs no conversion.

Recalculate on dragend, not drag. drag fires on every frame, and one request per frame is slow and wasteful. The answer only matters once the pin is dropped.

For a few hundred points against one polygon, this loop takes less time than the request. For polygon against polygon, or many thousands of points, use a geometry library such as Turf.

Next steps

With travel time bands, testing each location against the bands from the smallest outward tells you which band it falls in, not only in or out.

To rank locations by exact travel time rather than a yes or no, the Matrix API does it in one request, as in Find the Nearest Location.

The same example in Maptoolkit Maps JS is Find Locations Inside an Isochrone.