Map your delivery coverage
Every delivery business hits the same question: how far should we deliver? Too small a zone and you leave money on the table. Too big and your drivers spend more time on the road than making deliveries. The answer isn't a radius on a map -- it's drive time. A 5km delivery in downtown gridlock takes longer than 15km on an empty highway.
This tutorial builds a delivery zone visualizer. It generates 15, 30, and 45 minute driving isochrones from your store location, layers them on a map with different colors, and lets customers check if their address is inside a zone. We'll also calculate a delivery fee based on which zone they fall in.
What we're using
- Isochrone API to generate driving time polygons
- Geocoding to resolve customer addresses to coordinates
- MapLibre GL JS to render the zone map
The full page
Save this as delivery-zones.html, drop in your API key, and open it.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Delivery zones</title>
<script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.css" />
<style>
body { margin: 0; font-family: system-ui, sans-serif; }
#map { position: absolute; inset: 0; }
#panel {
position: absolute; top: 12px; right: 12px; z-index: 1;
background: white; padding: 16px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15); width: 320px;
}
h2 { margin: 0 0 12px; font-size: 18px; }
input { width: 100%; padding: 8px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 4px; }
button { margin-top: 8px; padding: 8px 16px; cursor: pointer; border: none; background: #059669; color: white; border-radius: 4px; width: 100%; }
#result { margin-top: 12px; padding: 12px; border-radius: 6px; display: none; }
.legend { margin-top: 16px; font-size: 13px; }
.legend-item { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.legend-dot { width: 14px; height: 14px; border-radius: 3px; }
</style>
</head>
<body>
<div id="map"></div>
<div id="panel">
<h2>Delivery zone checker</h2>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:#22c55e"></div> 0-15 min: free delivery</div>
<div class="legend-item"><div class="legend-dot" style="background:#eab308"></div> 15-30 min: $4.99 delivery</div>
<div class="legend-item"><div class="legend-dot" style="background:#ef4444"></div> 30-45 min: $8.99 delivery</div>
</div>
<div style="margin-top: 16px">
<input id="address" type="text" placeholder="Enter your delivery address" />
<button onclick="checkAddress()">Check my address</button>
</div>
<div id="result"></div>
</div>
<script>
const API_KEY = "pk_live_YOUR_KEY";
const BASE = "https://plaza.fyi/api/v1";
const headers = { "x-api-key": API_KEY };
// Your store location -- change this to your actual address
const STORE = { lat: 40.7580, lng: -73.9855 }; // Times Square, NYC
const ZONES = [
{ time: 2700, color: "#ef4444", opacity: 0.15, fee: "$8.99", label: "30-45 min" },
{ time: 1800, color: "#eab308", opacity: 0.20, fee: "$4.99", label: "15-30 min" },
{ time: 900, color: "#22c55e", opacity: 0.25, fee: "Free", label: "0-15 min" },
];
const map = new maplibregl.Map({
container: "map",
style: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
center: [STORE.lng, STORE.lat],
zoom: 12,
});
// Store marker
new maplibregl.Marker({ color: "#1e40af" })
.setLngLat([STORE.lng, STORE.lat])
.setPopup(new maplibregl.Popup().setHTML("<b>Your store</b>"))
.addTo(map);
// Load isochrone zones on map load
map.on("load", async () => {
for (const zone of ZONES) {
const resp = await fetch(
`${BASE}/isochrone?lat=${STORE.lat}&lng=${STORE.lng}&time=${zone.time}&mode=auto`,
{ headers }
);
const data = await resp.json();
map.addSource(`zone-${zone.time}`, { type: "geojson", data });
map.addLayer({
id: `zone-fill-${zone.time}`,
type: "fill",
source: `zone-${zone.time}`,
paint: { "fill-color": zone.color, "fill-opacity": zone.opacity },
});
map.addLayer({
id: `zone-line-${zone.time}`,
type: "line",
source: `zone-${zone.time}`,
paint: { "line-color": zone.color, "line-width": 2, "line-opacity": 0.6 },
});
}
});
async function checkAddress() {
const addr = document.getElementById("address").value;
const resultDiv = document.getElementById("result");
if (!addr) return;
resultDiv.style.display = "block";
resultDiv.style.background = "#f3f4f6";
resultDiv.textContent = "Checking...";
// Geocode the address
const resp = await fetch(
`${BASE}/geocode?q=${encodeURIComponent(addr)}&lat=${STORE.lat}&lng=${STORE.lng}`,
{ headers }
);
const geo = await resp.json();
if (!geo.results?.length) {
resultDiv.style.background = "#fef2f2";
resultDiv.textContent = "Could not find that address. Try including the city and state.";
return;
}
const { lat, lng } = geo.results[0];
// Remove old marker if any
if (window._custMarker) window._custMarker.remove();
window._custMarker = new maplibregl.Marker({ color: "#6b21a8" })
.setLngLat([lng, lat])
.addTo(map);
// Check which zone the point falls in using point-in-polygon
// We check from smallest (fastest) to largest
const zonesSmallFirst = [...ZONES].reverse();
let matchedZone = null;
for (const zone of zonesSmallFirst) {
const src = map.getSource(`zone-${zone.time}`);
if (!src) continue;
const data = src._data || src.serialize().data;
if (data && pointInPolygon([lng, lat], data)) {
matchedZone = zone;
break;
}
}
if (matchedZone) {
const bgColors = { Free: "#f0fdf4", "$4.99": "#fefce8", "$8.99": "#fef2f2" };
resultDiv.style.background = bgColors[matchedZone.fee] || "#f0fdf4";
resultDiv.innerHTML = `We deliver here! <b>${matchedZone.label}</b> zone.<br>Delivery fee: <b>${matchedZone.fee}</b>`;
} else {
resultDiv.style.background = "#fef2f2";
resultDiv.innerHTML = "Sorry, this address is outside our delivery area.";
}
map.flyTo({ center: [lng, lat], zoom: 13 });
}
// Basic point-in-polygon (ray casting) for GeoJSON polygons
function pointInPolygon(point, geojson) {
const coords = geojson.geometry?.coordinates;
if (!coords) return false;
// Handle Polygon and MultiPolygon
const rings = geojson.geometry.type === "MultiPolygon"
? coords.flatMap((p) => p)
: coords;
for (const ring of [rings[0]]) {
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 > point[1] !== yj > point[1] &&
point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) {
inside = !inside;
}
}
if (inside) return true;
}
return false;
}
</script>
</body>
</html>
Breaking it down
Generating the isochrone zones
When the map loads, we request three isochrones from Plaza:
GET /api/v1/isochrone?lat=40.758&lng=-73.9855&time=900&mode=auto
GET /api/v1/isochrone?lat=40.758&lng=-73.9855&time=1800&mode=auto
GET /api/v1/isochrone?lat=40.758&lng=-73.9855&time=2700&mode=auto
Each returns a GeoJSON polygon representing everywhere you can drive within that many seconds. We render them largest first (45 min on bottom, 15 min on top) so the colors layer properly.
The shapes you get back aren't circles. They're organic blobs that follow the road network. A 15-minute zone in Manhattan is small and dense because traffic is slow. The same 15-minute zone from a suburban store stretches way further along highways. Circles would lie about this. Isochrones don't.
Checking if an address is in a zone
When a customer types their address, we geocode it (with a focus point bias toward the store location, so "Main St" resolves nearby rather than in some other state) and then check which isochrone polygon contains that point.
We check from the smallest zone outward. If the point is inside the 15-minute polygon, that's the zone. If not, check 30, then 45. If it's not in any of them, they're out of range.
The point-in-polygon check here is client-side ray casting. For a production app you might want to do this server-side, but for a visualizer like this, the client-side check works fine and avoids an extra API call.
Setting delivery fees
The fee structure is straightforward:
| Zone | Drive time | Fee |
|---|---|---|
| Green | 0-15 min | Free |
| Yellow | 15-30 min | $4.99 |
| Red | 30-45 min | $8.99 |
You'd adjust these for your business. Some restaurants do flat-rate per zone, some scale linearly with distance, some charge nothing and fold it into food prices. The isochrones give you the raw data to implement whatever pricing model makes sense.
Making it yours
The STORE constant at the top is the obvious first edit. If you have multiple locations, generate isochrones for each and let customers see which store would deliver to them fastest.
Time thresholds depend on what you're delivering. Pizza places might care about 10/20/30 minutes. Grocery delivery thinks in 30/60/90 minute windows. Edit the ZONES array.
For multiple stores, render each location's isochrones in a different color family. When a customer enters their address, check which store's zone they fall in -- the closest store by drive time (not distance) handles the delivery.
One thing to keep in mind: traffic changes throughout the day. Your 15-minute zone at 2pm looks very different from your 15-minute zone at 5:30pm. You could re-fetch isochrones periodically and update the map. Plaza's isochrone calculation uses road network data but doesn't account for live traffic, so the results represent typical conditions.
The whole page is self-contained HTML. No build step, no server needed beyond Plaza's API. Drop it on your site, stick it in an iframe, whatever works.