All roads lead to Rome
The fastest drive to the Colosseum from 388 towns, spread evenly across Europe. Where routes share a road, its line gets thicker, until the last miles into Rome carry all of them.
Build it yourself
A Node.js script routes from about 390 towns to the Colosseum and saves the roads they share as GeoJSON, and a page draws them on a dark style of its own. It's a simpler version of the map above, without the starting towns or the route on click. It was tested with MapLibre GL JS 6.15 in Chromium and Node.js 22.
What you need
- A server key with the Routing product, made as Getting started shows. The script runs on your computer, so the key stays secret.
- Node.js 18 or later.
- The map tiles need no key.
Collect the routes
Make a folder for the map, and in it a file called routes.mjs:
import { writeFile } from "node:fs/promises";
const colosseum = { lat: 41.8902, lon: 12.4922 };
// Routes come as polylines with six decimal places; this gives [lon, lat] pairs.
function decode(polyline) {
const points = [];
const position = [0, 0];
let index = 0;
while (index < polyline.length) {
for (const axis of [0, 1]) {
let shift = 0;
let value = 0;
let byte;
do {
byte = polyline.charCodeAt(index++) - 63;
value |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
position[axis] += value & 1 ? ~(value >> 1) : value >> 1;
}
points.push([position[1] / 1e6, position[0] / 1e6]);
}
return points;
}
// The most populous town in each cell of a grid over Europe, one degree of latitude by one and a half of longitude.
const response = await fetch(
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_populated_places_simple.geojson",
);
const places = await response.json();
const outside = new Set(["DZA", "LBY", "MAR", "SYR", "TUN", "TUR", "CYP"]);
const towns = new Map();
for (const { properties: town } of places.features) {
const { latitude: lat, longitude: lon } = town;
if (lon < -10.5 || lon > 32 || lat < 36 || lat > 60.5 || outside.has(town.adm0_a3)) continue;
const cell = `${Math.floor(lat)},${Math.floor(lon / 1.5)}`;
if (!towns.has(cell) || town.pop_max > towns.get(cell).pop_max) towns.set(cell, town);
}
// The drive from each town, rounded to two decimal places (about half a mile) so routes on the same road line up.
const routes = [];
for (const town of towns.values()) {
const response = await fetch("https://api.mapricot.com/v1/route", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.MAPRICOT_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ waypoints: [{ lat: town.latitude, lon: town.longitude }, colosseum] }),
});
if (!response.ok) {
console.warn(`No route from ${town.name}: ${response.status}`);
continue;
}
const { routes: [route] } = await response.json();
const points = decode(route.polyline).map(([lon, lat]) => [Math.round(lon * 100) / 100, Math.round(lat * 100) / 100]);
routes.push(points.filter((point, i) => i === 0 || point.join() !== points[i - 1].join()));
}
// How many routes use each stretch between two rounded points.
const segment = (a, b) => `${a}|${b}`;
const counts = new Map();
for (const points of routes) {
for (let i = 1; i < points.length; i++) {
const key = segment(points[i - 1], points[i]);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
}
// Each stretch drawn once, joined into lines while the count stays the same.
const drawn = new Set();
const features = [];
for (const points of routes) {
let line = null;
for (let i = 1; i < points.length; i++) {
const key = segment(points[i - 1], points[i]);
if (drawn.has(key) || counts.get(key) !== line?.properties.routes) line = null;
if (drawn.has(key)) continue;
drawn.add(key);
if (!line) {
line = { type: "Feature", properties: { routes: counts.get(key) }, geometry: { type: "LineString", coordinates: [points[i - 1]] } };
features.push(line);
}
line.geometry.coordinates.push(points[i]);
}
}
await writeFile("rome.geojson", JSON.stringify({ type: "FeatureCollection", features }));
console.log(`Saved ${routes.length} routes as ${features.length} lines`);
- The grid keeps the starting towns spread out. Without it, nearly all of them would be in the most crowded countries.
outsideleaves out countries in Natural Earth's box around Europe that aren't in Europe, or don't drive to Rome.- Rounding each point to two decimal places, about half a mile, makes routes along the same road share the same points, so their stretches can be counted together.
- Lines are joined while the count stays the same, so the file holds about 1,300 lines instead of tens of thousands of short stretches.
Run it with your server key:
MAPRICOT_KEY=mapricot_sk_… node routes.mjs
It sends one request at a time and takes a few minutes, then prints Saved 388 routes as 1296 lines, or close to it, and writes rome.geojson.
Make the page
Next to rome.geojson, make index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>All roads lead to Rome</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
</head>
<body style="margin: 0">
<div id="map" style="height: 100vh"></div>
<script type="module">
import * as maplibregl from "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm";
const width = ["interpolate", ["linear"], ["sqrt", ["get", "routes"]], 1, 0.8, 20, 10];
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
protomaps: { type: "vector", url: "https://tiles.mapricot.com/planet.json" },
routes: { type: "geojson", data: "rome.geojson", attribution: "Towns: Natural Earth" },
},
layers: [
{ id: "background", type: "background", paint: { "background-color": "#0d1117" } },
{ id: "earth", type: "fill", source: "protomaps", "source-layer": "earth", paint: { "fill-color": "#1c2128" } },
{
id: "water",
type: "fill",
source: "protomaps",
"source-layer": "water",
filter: ["==", ["geometry-type"], "Polygon"],
paint: { "fill-color": "#0d1117" },
},
{
id: "borders",
type: "line",
source: "protomaps",
"source-layer": "boundaries",
filter: ["<=", ["get", "kind_detail"], 2],
paint: { "line-color": "#373e47", "line-width": 0.6 },
},
{
id: "routes",
type: "line",
source: "routes",
layout: { "line-cap": "round", "line-join": "round", "line-sort-key": ["get", "routes"] },
paint: {
"line-color": ["interpolate", ["linear"], ["get", "routes"], 1, "#9a4a14", 20, "#fd7e14", 150, "#ffc078"],
"line-width": ["interpolate", ["linear"], ["zoom"], 3, ["*", 0.5, width], 8, ["*", 1.5, width]],
},
},
],
},
bounds: [-10, 36, 32, 60.5],
});
</script>
</body>
</html>
- The style is a few layers of its own on the map tiles: dark land, darker water, and country borders, with no roads or labels to compete with the routes.
- The
routessource is the file the script saved, listed in the style next to the tiles. - Line width grows with the square root of the count, so a road that 100 routes share is ten times as wide as one route, not a hundred. Busier roads are lighter and drawn on top, with
line-sort-key.
Open it
Serve the folder with npx serve or python3 -m http.server, and open the address it prints. Opened straight from the file, the map stays blank, as Open the page explains. You should see Europe in the dark, with orange roads thickening as they near Rome.
The idea comes from moovel lab's Roads to Rome, which routed almost 500,000 starting points across Europe. To draw a single route, see Draw a route with MapLibre GL JS.
Data
- Routes: Mapricot's routing, from OpenStreetMap contributors (ODbL)
- Starting towns: Natural Earth populated places (public domain)
- Land, water, and borders: OpenStreetMap and Natural Earth, in Mapricot's map tiles