A tiny web app to create images from OpenStreetMap maps
12th June 2022
Earlier today I found myself wanting to programmatically generate some images of maps.
I wanted to create a map centered around a location, at a specific zoom level, and with a marker in a specific place.
Some cursory searches failed to turn up exactly what I wanted, so I decided to build a tiny project to solve the problem, taking advantage of my shot-scraper tool for automating screenshots of web pages.
The result is map.simonwillison.net—hosted on GitHub Pages from my simonw/url-map repository.
Here’s how to generate a map image of Washington DC:
shot-scraper 'https://map.simonwillison.net/?q=washington+dc' \
--retina --width 600 --height 400 --wait 3000
That command generates a PNG 1200x800 image that’s a retina screenshot of the map displayed at https://map.simonwillison.net/?q=washington+dc—after waiting three seconds to esure all of the tiles have fully loaded.
The website itself is documented here. It displays a map with no visible controls, though you can use gestures to zoom in and pan around—and the URL bar will update to reflect your navigation, so you can bookmark or share the URL once you’ve got it to the right spot.
You can also use query string parameters to specify the map that should be initially displayed:
- https://map.simonwillison.net/?center=51.49,0&zoom=8 displays a map at zoom level 8 centered on the specified latitude, longitude coordinate pair.
-
https://map.simonwillison.net/?q=islington+london geocodes the
?q=
text using OpenStreetMap Nominatim and zooms to the level that best fits the bounding box of the first returned result. - https://map.simonwillison.net/?q=islington+london&zoom=12 does that but zooms to level 12 instead of using the best fit for the bounding box
-
https://map.simonwillison.net/?center=51.49,0&zoom=8&marker=51.49,0&marker=51.3,0.2 adds two blue markers to the specified map. You can pass
&marker=lat,lon
as many times as you like to add multiple markers.
Annotated source code
The entire mapping application is contained in a single 68 line index.html
file that mixes HTML and JavaScript. It’s built using the fantastic Leaflet open source mapping library.
Since the code is so short, I’ll enclude the entire thing here with some additional annotating comments.
It started out as a copy of the first example in the Leaflet quick start guide.
<!DOCTYPE html>
<!-- Regular HTML boilerplate -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>map.simonwillison.net</title>
<!--
Leaflet's CSS and JS are loaded from the unpgk.com CDN, with the
Subresource Integrity (SRI) integrity="sha512..." attribute to ensure
that the exact expected code is served by the CDN.
-->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.8.0/dist/leaflet.css" integrity="sha512-hoalWLoI8r4UszCkZ5kL8vayOGVae1oxXe/2A4AO6J9+580uKHDO3JdHb7NzwwzK5xr/Fs0W40kiNHxM9vyTtQ==" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.8.0/dist/leaflet.js" integrity="sha512-BB3hKbKWOc9Ez/TAwyWxNXeoV9c1v6FIeYiBieIWkpLjauysF18NzgR1MBNBXf8/KABdlkX68nAhlwcDFLGPCQ==" crossorigin=""></script>
<!-- I want the map to occupy the entire browser window with no margins -->
<style>
html, body {
height: 100%;
margin: 0;
}
</style>
</head>
<body>
<!-- The Leaflet map renders in this 100% high/wide div -->
<div id="map" style="width: 100%; height: 100%;"></div>
<script>
function toPoint(s) {
// Convert "51.5,2.1" into [51.5, 2.1]
return s.split(",").map(parseFloat);
}
// An async function so we can 'await fetch(...)' later on
async function load() {
// URLSearchParams is a fantastic browser API - it makes it easy to both read
// query string parameters from the URL and later to generate new ones
let params = new URLSearchParams(location.search);
// If the starting URL is /?center=51,32&zoom=3 this will pull those values out
let center = params.get('center') || '0,0';
let initialZoom = params.get('zoom');
let zoom = parseInt(initialZoom || '2', 10);
let q = params.get('q');
// .getAll() turns &marker=51.49,0&marker=51.3,0.2 into ['51.49,0', '51.3,0.2']
let markers = params.getAll('marker');
// zoomControl: false turns off the visible +/- zoom buttons in Leaflet
let map = L.map('map', { zoomControl: false }).setView(toPoint(center), zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
// This option means retina-capable devices will get double-resolution tiles:
detectRetina: true
}).addTo(map);
// We only pay attention to ?q= if ?center= was not provided:
if (q && !params.get('center')) {
// We use fetch to pass ?q= to the Nominatim API and get back JSON
let response = await fetch(
`https://nominatim.openstreetmap.org/search.php?q=${encodeURIComponent(q)}&format=jsonv2`
)
let data = await response.json();
// data[0] is the first result - it has a boundingbox array of four floats
// which we can convert into a Leaflet-compatible bounding box like this:
let bounds = [
[data[0].boundingbox[0],data[0].boundingbox[2]],
[data[0].boundingbox[1],data[0].boundingbox[3]]
];
// This sets both the map center and zooms to the correct level for the bbox:
map.fitBounds(bounds);
// User-provided zoom over-rides this
if (initialZoom) {
map.setZoom(parseInt(initialZoom));
}
}
// This is the code that updates the URL as the user pans or zooms around.
// You can subscribe to both the moveend and zoomend Leaflet events in one go:
map.on('moveend zoomend', () => {
// Update URL bar with current location
let newZoom = map.getZoom();
let center = map.getCenter();
// This time we use URLSearchParams to construct a center...=&zoom=... URL
let u = new URLSearchParams();
// Copy across ?marker=x&marker=y from existing URL, if they were set:
markers.forEach(s => u.append('marker', s));
u.append('center', `${center.lat},${center.lng}`);
u.append('zoom', newZoom);
// replaceState() is a weird API - the third argument is the one we care about:
history.replaceState(null, null, '?' + u.toString());
});
// This bit adds Leaflet markers to the map for ?marker= query string arguments:
markers.forEach(s => {
L.marker(toPoint(s)).addTo(map);
});
}
load();
</script>
</body>
</html>
<!-- See https://github.com/simonw/url-map for documentation -->
More recent articles
- Qwen2.5-Coder-32B is an LLM that can code well that runs on my Mac - 12th November 2024
- Visualizing local election results with Datasette, Observable and MapLibre GL - 9th November 2024
- Project: VERDAD - tracking misinformation in radio broadcasts using Gemini 1.5 - 7th November 2024