Using Valhalla for Routing in Mapbox
Valhalla is the MIT licensed open source routing engine used by Mapbox and Tesla. In this blog post, I’ll run Valhalla from docker compose to provide routing information to a Typescript React frontend.
Docker Configuration
First we’ll create a custom_files/
directory in our project directory for
the Open Street Map PBF data. Then we can point to that directory in the
docker compose file.
There is also an option, using the tile_urls
environment variable to
download PBF data directly from the Geofabrik server. I used a Hawaii tile
for testing because it was a small(er) PBF file.
The important part here is that we’re using local PBF data, and that we’re hosting Valhalla on the default port 8002.
services:
web:
build: ./backend
ports:
- "8000:8000"
depends_on:
- db
environment:
- MONGO_URI=mongodb://db:27017
db:
image: mongo:latest
ports:
- "27017:27017"
volumes:
- mongodb_data:/data/db
valhalla:
image: ghcr.io/gis-ops/docker-valhalla/valhalla:latest
ports:
- 8002:8002
volumes:
- ./custom_files/:/custom_files # if using local files
environment:
# auto-download PBFs from Geofabrik
# - tile_urls=https://download.geofabrik.de/europe/andorra-latest.osm.pbf https://download.geofabrik.de/europe/albania-latest.osm.pbf
- server_threads=2 # determines how many threads will be used to run the valhalla server
- serve_tiles=True # If True, starts the service. If false, stops after building the graph.
- use_tiles_ignore_pbf=False # load existing valhalla_tiles.tar directly
- tileset_name=valhalla_tiles # name of the resulting graph on disk
- build_elevation=False # build elevation with "True" or "Force": will download only the elevation for areas covered by the graph tiles
- build_admins=False # build admins db with "True" or "Force"
- build_time_zones=False # build timezone db with "True" or "Force"
- build_transit=False # build transit, needs existing GTFS directories mapped to /gtfs_feeds
- build_tar=False # build an indexed tar file from the tile_dir for faster graph loading times
- force_rebuild=True # forces a rebuild of the routing tiles with "True"
- update_existing_config=True # if there are new config entries in the default config, add them to the existing config
# - path_extension=graphs # this path will be internally appended to /custom_files; no leading or trailing path separator!
volumes:
mongodb_data:
React Client
Since this is a simple proof of concept, the client it pulling route data directly from the Valhalla server, and not using backend API. In a production environment, route requests would go through a loadbalancer, and then to a dedicated routing server cluster, with caching and analytics along the way.
The only gotcha was that Mapbox expects (longitude, latitude) pairs, while Valhalla returns (latitude, longitude) pairs, and that the Valhalla had scaled the coordinates to 10x what I expected, i.e., a 20 degree latitude was 200 degrees. Besides that, adding a source GeoJSON and a layer was pretty easy.
NOTE – We’re drawing the route on the map using the function
displayRouteOnMapbox
. This works by adding a source named route
to the
mapbox object, and then adding a layer that references the source named
route
. So in a more dynamic system we would keep track of sources and layers
elsewhere, and add, remove, or modify them as needed. In this app, we’re just
painting the route on the map directly.
import React, { useRef, useEffect } from 'react';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import mapboxgl from 'mapbox-gl';
import polyline from '@mapbox/polyline';
import 'mapbox-gl/dist/mapbox-gl.css'
const Map: React.FC = () => {
const mapContainer = useRef<HTMLDivElement>(null);
let map = useRef<mapboxgl.Map | null>(null);
const lng = -156.4;
const lat = 20.7;
const zoom = 10;
const [mapLoaded, setMapLoaded] = React.useState(false);
const [originLat, setOriginLat] = React.useState(20.71631);
const [originLng, setOriginLng] = React.useState(-156.44612);
const [destLat, setDestLat] = React.useState(20.78871);
const [destLng, setDestLng] = React.useState(-156.46741);
useEffect(() => {
mapboxgl.accessToken = import.meta.env.VITE_MAPBOX_ACCESS_TOKEN; // Set your access token
if (!mapContainer.current) {
return;
}
map.current = new mapboxgl.Map({
container: mapContainer.current,
style: 'mapbox://styles/mapbox/streets-v12',
center: [lng, lat],
zoom: zoom
});
map.current.on('load', () => { // Listen for the 'load' event
setMapLoaded(true); // Set state to true when the map is loaded
});
// Add zoom controls
map.current.addControl(new mapboxgl.NavigationControl(), "top-left");
}, []);
async function getDirections() {
const serverUrl = "http://localhost:8002/route";
const url = `${serverUrl}?json={"locations":[{"lat":${originLat},"lon":${originLng}},{"lat":${destLat},"lon":${destLng}}],"costing":"auto"}`;
let resp;
try {
resp = await fetch(url);
} catch (error) {
console.error('Error fetching Valhalla route:', error);
return;
}
let data;
try {
data = await resp.json();
} catch (error) {
console.error('Error parsing Valhalla route JSON:', error);
return;
}
if (data.trip && data.trip.legs && data.trip.legs.length > 0 && data.trip.legs[0].shape) {
const encodedPolyline = data.trip.legs[0].shape;
// Decode the polyline
const decodedPolyline = polyline.decode(encodedPolyline);
// Convert to GeoJSON format (Mapbox requires [longitude, latitude])
const geojson: GeoJSON.Feature<GeoJSON.LineString> = {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: decodedPolyline.map(coords => [0.1*coords[1], 0.1*coords[0]]) // Important: [lng, lat]
},
properties: {}
};
displayRouteOnMapbox(geojson);
} else {
console.error('Valhalla route not found or missing shape:', data);
}
}
function displayRouteOnMapbox(geojson: GeoJSON.Feature<GeoJSON.LineString>) {
console.log(`maploaded: ${mapLoaded}, geojson: ${JSON.stringify(geojson)}`);
if (!mapLoaded) {
console.error('Map not loaded yet');
return;
}
map.current.addSource('route', {
'type': 'geojson',
'data': geojson
});
map.current.addLayer({
'id': 'route',
'type': 'line',
'source': 'route',
'layout': {
'line-join': 'round',
'line-cap': 'round'
},
'paint': {
'line-color': '#ff4f84',
'line-width': 3,
'line-opacity': 0.5
}
});
}
return (
<div>
<div className="sidebarStyle">
<div>Longitude: {lng} | Latitude: {lat} | Zoom: {zoom}</div>
</div>
<div>
<TextField
id="origin-lat"
label="Origin Latitude"
variant="outlined"
value={originLat}
onChange={(e) => setOriginLat(parseFloat(e.target.value))}
margin="normal" // Adds some vertical spacing
/>
<TextField
id="origin-lng"
label="Origin Longitude"
variant="outlined"
value={originLng}
onChange={(e) => setOriginLng(parseFloat(e.target.value))}
margin="normal"
/>
<TextField
id="dest-lat"
label="Destination Latitude"
variant="outlined"
value={destLat}
onChange={(e) => setDestLat(parseFloat(e.target.value))}
margin="normal"
/>
<TextField
id="dest-lng"
label="Destination Longitude"
variant="outlined"
value={destLng}
onChange={(e) => setDestLng(parseFloat(e.target.value))}
margin="normal"
/>
{mapLoaded &&
<div>
<Button variant="contained" onClick={getDirections}>
Get Directions
</Button>
</div>
}
</div>
<div ref={mapContainer} className="map-container" style= />
</div>
);
};
export default Map;