Leaflet is a popular JavaScript map component library with an extensive ecosystem of plugins and integrations. I have been using it in several projects and it works really well as a UI component. At one point, I needed to produce a static image of the map with some overlays, which is something that Leaflet doesn't support out of the box, nor has a plugin that worked for me. Since the images would be produced based on user input, they had to be generated on the spot and not created in advance. My options were:
- Use a third party service to generate the map
- Write server code that would run a headless browser (eg. with Puppeteer) and take a snapshot of the map
- Find some way to take a "snapshot" of the map in the client code running in the browser
The last option was preferable because it doesn't rely on anything outside the app scope running in the browser. The solution came from dom-to-image, a JavaScript library which magically does exactly what I needed.
Trying to create a Leaflet image and use dom-to-image did not work straightforward, but it was relatively simple to get it to produce a proper image. In the following example, based on an example from Leaflet docs, I will explain the required steps.
Creating and configuring the Leaflet map
By default Leaflet maps include zoom control and attribution. The zoom control is definitely not needed in the image, and you may want to have the attribution outside the image as text or not at all (depending on your tile provider requirements). Additionally by default, there are animations for tile loading and zooming, which we want to avoid and get only the "final" state of the map, so we need to disable the animations.
To achieve these, we create the map like so:
const map = L.map(
attributionControl: false,
zoomControl: false,
fadeAnimation: false,
zoomAnimation: false
})
Then add whatever layers and overlays you need. In our example, we use OpenStreetMap tiles and we add a marker, a circle and a polygon (see the complete example below).
Waiting for the tiles to load
The synchronous DOM and Leaflet operations do not guarantee that the map content is in its final desired form. Right after the map is added to the DOM, the tiles begin to download and we need to produce the image only after all the tiles have finished downloading. Leaflet tile layers fire the load
event when all the tiles have been downloaded and are displayed, so we can use that to know when we can continue:
const tileLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png")
.addTo(map)
// ... Additional map operations ...
tileLayer.on("load", () => { /* produce the image */ })
I prefer working with async/await rather than callbacks, so I "turned" the use of callback into a promise like so:
await new Promise(resolve => tileLayer.on("load", () => resolve()))
Using dom-to-image to produce the image
Now that we know the map component is in the desired state, we can export it to an image. To get a PNG Data URL we would use dom-to-image toPng
function. Note that you need to specify the same width and height as you did for the Leaflet element:
const dataURL = await domtoimage.toPng(mapElement, { width, height })
You can also export to a blob and download it using FileSaver for example:
const blob = await domtoimage.toBlob(mapElement, { width, height })
saveAs(blob, 'map.png')
The complete example
An important point is to make sure the intermediate Leaflet map is not visible to the user, otherwise it will look like flicker. You can use absolute positioning and z-index to position the intermediate map element below the app UI elements (but don't try to place it outside the viewport or to set its display to none - these will prevent rendering the map content).
Although this post covered the case of Leaflet, a similar approach can be used with other map components and other components with no built-in image export functionality - make sure the component is fully loaded, use event listeners if needed, and then use dom-to-image to generate the image.
Top comments (1)
Thanks, it's really useful with the demo :)