Leaflet geocoding and routing
This page builds a Leaflet map with Mapricot's free vector tiles, a search box that suggests addresses as you type, and a route between two draggable points. It uses two common Leaflet plugins, Leaflet Control Geocoder and Leaflet Routing Machine, and gives each a few lines that call Mapricot's API. Everything goes in one HTML file, with no build step.
It was tested with Leaflet 1.9.4, MapLibre GL Leaflet 0.1.4, Leaflet Control Geocoder 4.0, and Leaflet Routing Machine 3.2.12, in Chromium.
Before you start
You need a browser key with the Places and Routing products. Getting started shows how to make one. Add http://localhost:* to its allowed origins, so it works from your computer while you build. The map tiles need no key.
1. Make the page
Make a folder for the map, and in it a file called index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Leaflet map</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css">
</head>
<body style="margin: 0">
<div id="map" style="height: 100vh"></div>
<script type="module">
import L from "https://cdn.jsdelivr.net/npm/leaflet@1.9.4/+esm";
import { maplibreGL } from "https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-leaflet@0.1.4/+esm";
const key = "mapricot_pk_…";
const map = L.map("map").setView([45.5189, -122.6793], 15);
maplibreGL({ style: "https://tiles.mapricot.com/styles/light.json" }).addTo(map);
</script>
</body>
</html>
Put your key in place of mapricot_pk_…. Here's what each part does:
- The
linkloads Leaflet's stylesheet, which styles the map's controls, markers, and popups. - The
mapelement is where the map is drawn. It needs a height; this one fills the window. type="module"makes the script a JavaScript module, so it canimportLeaflet and its plugins.- The imports load Leaflet and the MapLibre GL Leaflet plugin from jsDelivr, which serves any npm package.
+esmasks for it as a module, and the version in the URL keeps the page from changing when a new version comes out. - The key sits in the page, where anyone can read it. That's what browser keys are for: they only work from the origins you allowed.
L.mapmakes the map in themapelement, centered on Pioneer Courthouse Square in Portland. Leaflet takes coordinates as[latitude, longitude].- Mapricot's map tiles are vector tiles, which Leaflet can't draw by itself.
maplibreGLadds them as a layer drawn by MapLibre GL JS, which the plugin loads for you. The style comes inlight,dark, andgrayscale.
2. Open the page
Serve the folder with a web server, rather than opening the file directly. Opened from a file, the map stays blank with Worker failed to load, since browsers don't let a page from a file start MapLibre's web worker. Such a page also has no origin, so the API would refuse the key with 403 and "Key is not allowed from this origin." Any web server works. In the folder, run one of these:
npx serve
python3 -m http.server
npx serve needs Node.js, and serves the page at http://localhost:3000. Python's serves it at http://localhost:8000. Open the address to see a map of downtown Portland, and reload it after each change below.
3. Add a search box
Leaflet Control Geocoder adds a search box to the map. It takes any object with a geocode function, which gets the search text and returns a list of results. Add suggest and it suggests results as you type.
Add its stylesheet after Leaflet's, in the head:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-control-geocoder@4.0.0/dist/Control.Geocoder.css">
Import it after Leaflet:
import "https://cdn.jsdelivr.net/npm/leaflet-control-geocoder@4.0.0/+esm";
Then add a geocoder that calls Mapricot's search and autocomplete, at the end of the script:
async function search(path, params) {
const response = await fetch(`https://api.mapricot.com${path}?${new URLSearchParams(params)}`, {
headers: { Authorization: `Bearer ${key}` },
});
if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
const { results } = await response.json();
return results.map((result) => {
const center = L.latLng(result.lat, result.lon);
return { name: result.label, center, bbox: center.toBounds(500), properties: result };
});
}
function near(query) {
const { lat, lng } = map.getCenter();
return { q: query, focus: `${lat.toFixed(6)},${lng.toFixed(6)}` };
}
const geocoder = {
geocode: (query) => search("/v1/search", near(query)),
suggest: (query) => search("/v1/autocomplete", near(query)),
reverse: ({ lat, lng }) => search("/v1/reverse", { lat: lat.toFixed(6), lon: lng.toFixed(6) }),
};
L.Control.geocoder({ geocoder }).addTo(map);
Reload the page, click the magnifying glass, and type "powells city". Powell's City of Books shows up as you type. Choosing it zooms the map there and adds a marker.
searchsends the request with the key and turns each result into the shape the plugin wants: a name, a center, and a bounding box. Mapricot's results are points, sobboxis a square 500 meters across, about a third of a mile, which sets how far the map zooms in.nearadds the map's center asfocus, so places near what's on screen come first. Without it, "powells city" suggests two towns in Virginia before the bookstore. Mapricot takesfocusaslat,lonwith at most 10 decimal places, hencetoFixed(6).reverseis optional. With it, the same geocoder turns a clicked point into an address, as ingeocoder.reverse(event.latlng).- Importing the plugin adds
L.Control.geocoderto Leaflet.
To limit results to some countries, add country: "US,CA" to what near returns.
4. Add a route
Leaflet Routing Machine draws a route between markers that people can drag, with a panel showing its distance and time. It takes a router: an object whose route function gets the waypoints and calls back with routes. Mapricot's route line is an encoded polyline, which the @mapbox/polyline package decodes.
Add its stylesheet in the head:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.css">
Import it and the polyline package after Leaflet:
import "https://cdn.jsdelivr.net/npm/leaflet-routing-machine@3.2.12/+esm";
import polyline from "https://cdn.jsdelivr.net/npm/@mapbox/polyline@1.2.1/+esm";
Routing Machine is an older plugin that finds Leaflet as the global L. Leaflet sets L when it loads, so import Routing Machine after Leaflet, not before.
Then add a router that asks Mapricot's routing for a driving route, at the end of the script:
const router = {
async route(waypoints, callback, context) {
try {
const response = await fetch("https://api.mapricot.com/v1/route", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({
waypoints: waypoints.map(({ latLng }) => ({ lat: +latLng.lat.toFixed(6), lon: +latLng.lng.toFixed(6) })),
mode: "drive",
}),
});
if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
const { routes } = await response.json();
callback.call(context, null, routes.map((route) => ({
name: "",
coordinates: polyline.decode(route.polyline, 6).map(([lat, lng]) => L.latLng(lat, lng)),
summary: { totalDistance: route.distanceMeters, totalTime: route.durationSeconds },
inputWaypoints: waypoints,
waypoints,
instructions: [],
})));
} catch (error) {
callback.call(context, { status: -1, message: error.message });
}
},
};
L.Routing.control({
router,
units: "imperial",
waypoints: [L.latLng(45.5189, -122.6793), L.latLng(45.5084, -122.6655)],
}).addTo(map);
Reload the page. The route draws from the square across the Willamette River to OMSI, the science museum, and the panel shows "1.4 mi, 4 min". Drag either marker and it routes again.
- The router sends the waypoints to Mapricot, rounded to six decimal places. A dragged marker's position has more decimal places than the API takes.
- Mapricot's polyline has six decimal places, so it's decoded with precision 6. Each point comes out as
[latitude, longitude], the order Leaflet uses. summarygives the panel the route's distance in meters and time in seconds.units: "imperial"shows the distance in miles, or in yards under 1,000 yards.- Mapricot doesn't return turn-by-turn directions, so
instructionsis empty and the panel shows only the summary. - Errors go to the callback, which shows them in the panel.
Set mode to walk or bicycle for other kinds of trip, and pass up to 20 waypoints.
The whole page
Here's index.html with all of the above:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Leaflet map</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-control-geocoder@4.0.0/dist/Control.Geocoder.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.css">
</head>
<body style="margin: 0">
<div id="map" style="height: 100vh"></div>
<script type="module">
import L from "https://cdn.jsdelivr.net/npm/leaflet@1.9.4/+esm";
import { maplibreGL } from "https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-leaflet@0.1.4/+esm";
import "https://cdn.jsdelivr.net/npm/leaflet-control-geocoder@4.0.0/+esm";
import "https://cdn.jsdelivr.net/npm/leaflet-routing-machine@3.2.12/+esm";
import polyline from "https://cdn.jsdelivr.net/npm/@mapbox/polyline@1.2.1/+esm";
const key = "mapricot_pk_…";
const map = L.map("map").setView([45.5189, -122.6793], 15);
maplibreGL({ style: "https://tiles.mapricot.com/styles/light.json" }).addTo(map);
async function search(path, params) {
const response = await fetch(`https://api.mapricot.com${path}?${new URLSearchParams(params)}`, {
headers: { Authorization: `Bearer ${key}` },
});
if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
const { results } = await response.json();
return results.map((result) => {
const center = L.latLng(result.lat, result.lon);
return { name: result.label, center, bbox: center.toBounds(500), properties: result };
});
}
function near(query) {
const { lat, lng } = map.getCenter();
return { q: query, focus: `${lat.toFixed(6)},${lng.toFixed(6)}` };
}
const geocoder = {
geocode: (query) => search("/v1/search", near(query)),
suggest: (query) => search("/v1/autocomplete", near(query)),
reverse: ({ lat, lng }) => search("/v1/reverse", { lat: lat.toFixed(6), lon: lng.toFixed(6) }),
};
L.Control.geocoder({ geocoder }).addTo(map);
const router = {
async route(waypoints, callback, context) {
try {
const response = await fetch("https://api.mapricot.com/v1/route", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({
waypoints: waypoints.map(({ latLng }) => ({ lat: +latLng.lat.toFixed(6), lon: +latLng.lng.toFixed(6) })),
mode: "drive",
}),
});
if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
const { routes } = await response.json();
callback.call(context, null, routes.map((route) => ({
name: "",
coordinates: polyline.decode(route.polyline, 6).map(([lat, lng]) => L.latLng(lat, lng)),
summary: { totalDistance: route.distanceMeters, totalTime: route.durationSeconds },
inputWaypoints: waypoints,
waypoints,
instructions: [],
})));
} catch (error) {
callback.call(context, { status: -1, message: error.message });
}
},
};
L.Routing.control({
router,
units: "imperial",
waypoints: [L.latLng(45.5189, -122.6793), L.latLng(45.5084, -122.6655)],
}).addTo(map);
</script>
</body>
</html>
Without Routing Machine
If you only need to draw a route, you don't need the plugin. Skip step 4's stylesheet, Routing Machine import, and router, keep the polyline import, and add this at the end of the script instead:
const response = await fetch("https://api.mapricot.com/v1/route", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({
waypoints: [{ lat: 45.5189, lon: -122.6793 }, { lat: 45.5084, lon: -122.6655 }],
}),
});
const { routes } = await response.json();
L.polyline(polyline.decode(routes[0].polyline, 6), { color: "#fd7e14", weight: 5 }).addTo(map);
When no road connects the points, routes is empty and the request isn't billed. Show a route on a MapLibre map covers drawing routes, travel modes, and stops in more depth.
Credits
The map shows the OpenStreetMap credit for its tiles. Search and route responses list their own credits in attribution, such as OpenAddresses for an address. If your map shows those results, add the credits to Leaflet's attribution, for example with map.attributionControl.addAttribution("OpenAddresses").
Questions
Does Leaflet have a built-in geocoder? No. Leaflet Control Geocoder is the usual plugin. It ships with geocoders for several services, and takes your own, as above.
Is Leaflet Routing Machine still maintained? Its last release, 3.2.12, came out in 2022. It still works with Leaflet 1.9, as tested here. The router above is the only part that talks to Mapricot, so it carries over if you draw routes another way.
Can I use Mapricot with Leaflet's image tiles? Mapricot serves vector tiles only, so a Leaflet map shows them through the MapLibre GL Leaflet plugin, as above. Search and routing work with any tiles.
Can I install these with npm instead? Yes. Install leaflet, @maplibre/maplibre-gl-leaflet, maplibre-gl, leaflet-control-geocoder, leaflet-routing-machine, and @mapbox/polyline, and import them by name. A bundler such as Vite also needs MapLibre's worker set up, as MapLibre with React shows. React Leaflet with free vector tiles shows the map in a React app.