Find Locations Inside an Isochrone in Leaflet
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 Leaflet.
const API_KEY = "YOUR_API_KEY";
const MINUTES = 15;
// Stand-ins for your own data: anything with a coordinate works.
const PLACES = [
["Stephansplatz", 48.2085, 16.3725], ["Karlsplatz", 48.2005, 16.3700],
["Rathaus", 48.2108, 16.3573], ["Praterstern", 48.2183, 16.3920],
["Westbahnhof", 48.1968, 16.3380], ["Schwedenplatz", 48.2118, 16.3789],
["Belvedere", 48.1915, 16.3806], ["Augarten", 48.2265, 16.3760],
["Naschmarkt", 48.1985, 16.3631], ["Hauptbahnhof", 48.1856, 16.3760],
];
const map = L.map("map", { zoomControl: false }).setView([48.2032, 16.3722], 14);
L.control.zoom({ position: "topright" }).addTo(map);
L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`, {
maxZoom: 18,
attribution:
"© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
"© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
}).addTo(map);
const panel = L.control({ position: "topleft" });
panel.onAdd = () => L.DomUtil.create("div", "panel");
panel.addTo(map);
panel.getContainer().textContent = "Loading...";
const area = L.geoJSON(null, { style: { color: "#2171b5", weight: 2, fillOpacity: 0.18 }, interactive: false }).addTo(map);
const places = PLACES.map(([name, lat, lng]) => ({
name,
marker: L.circleMarker([lat, lng], { radius: 7, weight: 2, color: "#fff", fillColor: "#adb5bd", fillOpacity: 1 })
.bindTooltip(name, { permanent: true, direction: "bottom", offset: [0, 6], className: "place" })
.addTo(map),
}));
// 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 = L.marker([48.2082, 16.3722], { draggable: true }).addTo(map);
async function update() {
const { lat, lng } = origin.getLatLng();
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 feature = await (await fetch(url)).json();
area.clearLayers().addData(feature);
const reachable = [];
for (const place of places) {
const { lat, lng } = place.marker.getLatLng();
// GeoJSON coordinates are [lng, lat], so the point is tested in that order.
const inside = inGeometry([lng, lat], feature.geometry);
place.marker.setStyle({ fillColor: inside ? "#2171b5" : "#adb5bd" });
if (inside) reachable.push(place.name);
}
panel.getContainer().innerHTML =
`<strong>${reachable.length} of ${places.length} within ${MINUTES} min on foot</strong>` +
reachable.join(", ") +
'<div class="hint">Drag the pin to move the origin.</div>';
}
origin.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/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
#map { width: 100%; height: 100%; }
.panel {
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; }
.leaflet-tooltip.place { padding: 0 4px; border: none; box-shadow: none; background: rgba(255, 255, 255, 0.85); font: 12px system-ui, sans-serif; }
.leaflet-tooltip.place::before { display: none; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const API_KEY = "YOUR_API_KEY";
const MINUTES = 15;
// Stand-ins for your own data: anything with a coordinate works.
const PLACES = [
["Stephansplatz", 48.2085, 16.3725], ["Karlsplatz", 48.2005, 16.3700],
["Rathaus", 48.2108, 16.3573], ["Praterstern", 48.2183, 16.3920],
["Westbahnhof", 48.1968, 16.3380], ["Schwedenplatz", 48.2118, 16.3789],
["Belvedere", 48.1915, 16.3806], ["Augarten", 48.2265, 16.3760],
["Naschmarkt", 48.1985, 16.3631], ["Hauptbahnhof", 48.1856, 16.3760],
];
const map = L.map("map", { zoomControl: false }).setView([48.2032, 16.3722], 14);
L.control.zoom({ position: "topright" }).addTo(map);
L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`, {
maxZoom: 18,
attribution:
"© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
"© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
}).addTo(map);
const panel = L.control({ position: "topleft" });
panel.onAdd = () => L.DomUtil.create("div", "panel");
panel.addTo(map);
panel.getContainer().textContent = "Loading...";
const area = L.geoJSON(null, { style: { color: "#2171b5", weight: 2, fillOpacity: 0.18 }, interactive: false }).addTo(map);
const places = PLACES.map(([name, lat, lng]) => ({
name,
marker: L.circleMarker([lat, lng], { radius: 7, weight: 2, color: "#fff", fillColor: "#adb5bd", fillOpacity: 1 })
.bindTooltip(name, { permanent: true, direction: "bottom", offset: [0, 6], className: "place" })
.addTo(map),
}));
// 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 = L.marker([48.2082, 16.3722], { draggable: true }).addTo(map);
async function update() {
const { lat, lng } = origin.getLatLng();
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 feature = await (await fetch(url)).json();
area.clearLayers().addData(feature);
const reachable = [];
for (const place of places) {
const { lat, lng } = place.marker.getLatLng();
// GeoJSON coordinates are [lng, lat], so the point is tested in that order.
const inside = inGeometry([lng, lat], feature.geometry);
place.marker.setStyle({ fillColor: inside ? "#2171b5" : "#adb5bd" });
if (inside) reachable.push(place.name);
}
panel.getContainer().innerHTML =
`<strong>${reachable.length} of ${places.length} within ${MINUTES} min on foot</strong>` +
reachable.join(", ") +
'<div class="hint">Drag the pin to move the origin.</div>';
}
origin.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.
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.
Mind the coordinate order. Leaflet works in [lat, lng], GeoJSON in [lng, lat]. The test
runs on the raw polygon from the response, so each marker’s position is flipped to [lng, lat]
before it is tested. Mixing the two orders gives no error, just wrong answers. The request’s
point parameter is lat,lng, like Leaflet.
Reuse the layers. The area is one L.geoJSON layer that clearLayers().addData() refills,
and each place is a circleMarker that setStyle() recolors. Nothing is created again when the
origin moves. The names are permanent tooltips, styled as plain labels.
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 the MapLibre example Find the Nearest Location.
The same example in Maptoolkit Maps JS is Find Locations Inside an Isochrone.