Find Locations Inside an Isochrone in Maptoolkit Maps JS
Drawing a catchment is halfway. The question behind it is almost always which of your own locations sit inside it: which branches serve this address, which stops a courier can reach before closing, which listings to show first. That test is a point-in-polygon check you run on the response, and this example does it against a draggable origin so the answer updates as the origin moves.
const API_KEY = 'YOUR_API_KEY';
const MINUTES = 10;
const PROFILE = 'foot';
// 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 maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: [16.3722, 48.2082],
zoom: 13,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
// Ray casting: count crossings of a ray going right from the point.
function pointInRing(point, ring) {
const [x, y] = point;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i], [xj, yj] = ring[j];
const crosses = (yi > y) !== (yj > y) &&
x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (crosses) inside = !inside;
}
return inside;
}
// A Polygon is an outer ring followed by holes: inside the outer, outside every hole.
function pointInPolygon(point, polygon) {
const [outer, ...holes] = polygon;
if (!pointInRing(point, outer)) return false;
return !holes.some(hole => pointInRing(point, hole));
}
function pointInGeometry(point, geometry) {
if (geometry.type === 'Polygon') return pointInPolygon(point, geometry.coordinates);
if (geometry.type === 'MultiPolygon') return geometry.coordinates.some(p => pointInPolygon(point, p));
return false;
}
const origin = new maptoolkit.Marker({ draggable: true }).setLngLat([16.3722, 48.2082]);
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', PROFILE);
url.searchParams.set('format', 'geojson');
url.searchParams.set('api_key', API_KEY);
fetch(url)
.then(r => r.json())
.then(area => {
map.getSource('area').setData(area);
for (const f of LOCATIONS.features) {
f.properties.inside = pointInGeometry(f.geometry.coordinates, area.geometry);
}
map.getSource('locations').setData(LOCATIONS);
const reachable = LOCATIONS.features.filter(f => f.properties.inside);
document.getElementById('panel').innerHTML =
`<b>${reachable.length} of ${LOCATIONS.features.length} within ${MINUTES} min</b>` +
reachable.map(f => f.properties.name).join(', ') +
`<div class="hint">Drag the marker to move the origin.</div>`;
});
}
map.on('load', () => {
map.addSource('area', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addSource('locations', { type: 'geojson', data: LOCATIONS });
map.addLayer({
id: 'area-fill', type: 'fill', source: 'area',
paint: { 'fill-color': '#2171b5', 'fill-opacity': 0.18 }
});
map.addLayer({
id: 'area-outline', type: 'line', source: 'area',
paint: { 'line-color': '#2171b5', 'line-width': 2 }
});
map.addLayer({
id: 'locations', type: 'circle', source: 'locations',
paint: {
'circle-radius': 7,
'circle-color': ['case', ['get', 'inside'], '#2171b5', '#bdbdbd'],
'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': 11,
'text-offset': [0, 1.1],
'text-anchor': 'top'
},
paint: { 'text-color': '#333', 'text-halo-color': '#fff', 'text-halo-width': 1.2 }
});
origin.addTo(map);
origin.on('dragend', update);
update();
});<!DOCTYPE html>
<html lang="en">
<head>
<title>Locations Inside an Isochrone - Maptoolkit Maps JS</title>
<meta property="og:description" content="Test your own points against an isochrone polygon." />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
#map { width: 100%; height: 100%; }
#panel {
position: absolute; top: 10px; left: 10px; z-index: 999;
background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
font: 13px/1.5 system-ui, sans-serif; padding: 10px 12px; max-width: 220px;
}
#panel b { display: block; margin-bottom: 4px; }
#panel .hint { color: #666; font-size: 12px; }
</style>
</head>
<body>
<div id="map"></div>
<div id="panel">Loading...</div>
<script>
const API_KEY = 'YOUR_API_KEY';
const MINUTES = 10;
const PROFILE = 'foot';
// 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 maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: [16.3722, 48.2082],
zoom: 13,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
// Ray casting: count crossings of a ray going right from the point.
function pointInRing(point, ring) {
const [x, y] = point;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i], [xj, yj] = ring[j];
const crosses = (yi > y) !== (yj > y) &&
x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (crosses) inside = !inside;
}
return inside;
}
// A Polygon is an outer ring followed by holes: inside the outer, outside every hole.
function pointInPolygon(point, polygon) {
const [outer, ...holes] = polygon;
if (!pointInRing(point, outer)) return false;
return !holes.some(hole => pointInRing(point, hole));
}
function pointInGeometry(point, geometry) {
if (geometry.type === 'Polygon') return pointInPolygon(point, geometry.coordinates);
if (geometry.type === 'MultiPolygon') return geometry.coordinates.some(p => pointInPolygon(point, p));
return false;
}
const origin = new maptoolkit.Marker({ draggable: true }).setLngLat([16.3722, 48.2082]);
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', PROFILE);
url.searchParams.set('format', 'geojson');
url.searchParams.set('api_key', API_KEY);
fetch(url)
.then(r => r.json())
.then(area => {
map.getSource('area').setData(area);
for (const f of LOCATIONS.features) {
f.properties.inside = pointInGeometry(f.geometry.coordinates, area.geometry);
}
map.getSource('locations').setData(LOCATIONS);
const reachable = LOCATIONS.features.filter(f => f.properties.inside);
document.getElementById('panel').innerHTML =
`<b>${reachable.length} of ${LOCATIONS.features.length} within ${MINUTES} min</b>` +
reachable.map(f => f.properties.name).join(', ') +
`<div class="hint">Drag the marker to move the origin.</div>`;
});
}
map.on('load', () => {
map.addSource('area', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addSource('locations', { type: 'geojson', data: LOCATIONS });
map.addLayer({
id: 'area-fill', type: 'fill', source: 'area',
paint: { 'fill-color': '#2171b5', 'fill-opacity': 0.18 }
});
map.addLayer({
id: 'area-outline', type: 'line', source: 'area',
paint: { 'line-color': '#2171b5', 'line-width': 2 }
});
map.addLayer({
id: 'locations', type: 'circle', source: 'locations',
paint: {
'circle-radius': 7,
'circle-color': ['case', ['get', 'inside'], '#2171b5', '#bdbdbd'],
'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': 11,
'text-offset': [0, 1.1],
'text-anchor': 'top'
},
paint: { 'text-color': '#333', 'text-halo-color': '#fff', 'text-halo-width': 1.2 }
});
origin.addTo(map);
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 test runs on your side. Maptoolkit returns the reachable area; deciding what falls inside it is a geometry operation on the response, and for points it is small enough to write out.
pointInRing is the standard ray-casting test: follow a ray to the right from the point and
count how many times it crosses the ring. Odd means inside. The comparison
(yi > y) !== (yj > y) is what makes it robust, because it counts each edge only once even
when the ray passes exactly through a vertex, which is the case that breaks naive versions.
pointInPolygon exists because a GeoJSON Polygon is a list of rings, not one ring. The
first is the outer boundary and the rest are holes, and an isochrone genuinely has holes: a
park with no paths through it, or a block enclosed by a motorway, is unreachable in the time
even though it sits inside the outer shape. Testing only coordinates[0] reports those as
reachable. MultiPolygon matters too, because a short walking time near water can return
several disconnected pieces.
Coordinates go in as [lng, lat] throughout. The isochrone’s format=geojson geometry is
already in that order, and so are the map’s, which is why no conversion appears here. Only
the request itself takes lat,lng.
Recalculating on dragend rather than drag matters. drag fires continuously, and an API
request per frame is both slow and wasteful; the answer is only interesting once the marker
lands.
For polygon-on-polygon work, or many thousands of points, this is where a geometry library earns its place. For a few hundred points against one polygon, this loop runs faster than the request that fetched the polygon.
Next steps
Several bands make the answer finer than in or out: 5, 10 and 15 minute bands let you label each location with which band it falls in, tested against each polygon in turn from the innermost outward.
Once the origin is chosen by a person rather than dragged, an address search or the Geocoding API supplies the coordinate, and the Routing API answers the follow-up question of how to actually get to the one they picked.