Creating an interactive SVG map on the web with D3
Alexey Yakovlev
Posted on October 6, 2022
I was recently approached with a task I was unfamiliar with. It involved painting a SVG map on a webpage with clickable points of interest. The problem was that the user should be able to zoom and pan across the map. I found the information on the internet on this topic rather scarce. Hence I am here, telling you how I solved my problem. If you are the type of person looking for the complete code, see Conclusion.
For the tutorial I recommend using CodePen where the complete solution is hosted or a similar service since we are going to make a very small iterative project. We are not going to be using any frameworks or languages other than Babel to transpile the code.
Basic zooming and panning
First lets add basic HTML layout
<div class="container">
<svg id="map" width="100%" height="100%" style="background-color:red">
<image id="image" href="https://upload.wikimedia.org/wikipedia/commons/1/1e/Alexander_III_conquest_from_Issos_to_Babylon-fr.svg">
</svg>
</div>
And style it a litte
* {
margin: 0;
padding: 0;
}
.container {
width: 100%;
height: calc(100vh);
overflow: hidden;
}
What we see is a map of Alexander III conquest from Issos to Babylon in French. However we see only a part of the map and on larger screens on the side we get a large portion of red SVG background sticking out. What we want is an experience where the user is able to pan and zoom across the map. However they should not be allowed to get out of the map bounds.
In order to achieve this we need to use D3 library. It includes a lot of utility methods and modules that help you create charts, tables, maps and other interactive experiences on the web using JavaScript and HTML. Many higher level libraries use it as the groundwork. I will not go into detail about library API but rather leave relevant links where possible.
import * as d3 from "https://cdn.skypack.dev/d3@7.6.1";
// create a D3 selection of the SVG itself - https://github.com/d3/d3-selection
const svg = d3.select("#map");
// create a D3 selection of the image element
const image = svg.selectChild("#image");
// create and configure an instance of zoom behaviour - https://github.com/d3/d3-zoom
const zoom = d3.zoom().on("zoom", zoomed);
// apply configured zoom behaviour to our svg
svg.call(zoom);
function zoomed(event) {
const { transform } = event;
// apply calculated transform to the image
image.attr("transform", transform.toString());
}
Now when we run the code we should be able to pan across the image. But at the moment we are not bound to the image limits and may go outside of the bounds infinitely. To fix this we should use extent configuration for the zoom behaviour.
Scrolling to zoom inside the CodePen iframe seems to not be working on dev.to. Please open the CodePen in a different tab to see it properly.
const { width, height } = image.node().getBoundingClientRect();
const { width: svgWidth, height: svgHeight } = svg
.node()
.getBoundingClientRect();
const zoom = d3
.zoom()
// scale extent is how much you can zoom into or out of the image - https://github.com/d3/d3-zoom#zoom_scaleExtent
.scaleExtent([1, 8])
// extent is mostly used to calculate things and make them smooth during zooming and panning - https://github.com/d3/d3-zoom#zoom_extent
// by default it is the viewbox or width and height of the nearest SVG ancestor - this works for us
.extent([
[0, 0],
[svgWidth, svgHeight],
])
// translate extent is optional and is used to bound the viewport to the image - https://github.com/d3/d3-zoom#zoom_translateExtent
.translateExtent([
[0, 0],
[width, height],
])
.on("zoom", zoomed);
Now we are unable to pan outside the image. However we still see red background on the sides. This is clearly due to the fact that we are zoomed out too far. To fix this we need to make our scaleExtent
more accurate.
You may experiment and see that if we remove
scaleExtent
we would be able to zoom out far enough to fit the whole image in the viewport. In this casetranslateExtent
would not allow us to pan at all since the actual extent of the translate is already reached.
// minimum scale is the largest ratio between the container dimension and the image dimension
const minScale = Math.max(svgWidth / width, svgHeight / height);
const zoom = d3
.zoom()
// scale extent is how much you can zoom into or out of the image
.scaleExtent([minScale, 8])
.extent([
[0, 0],
[svgWidth, svgHeight],
])
.translateExtent([
[0, 0],
[width, height],
])
.on("zoom", zoomed);
// apply calculated default scale
zoom.scaleTo(svg, minScale);
At this point it may become clear that I chose a fairly poor image as an example since it too small to really justify the ability to zoom. But you get the point nevertheless!
Congrats! We got what we wanted to achieve. However there is still a thing we need check. What if we want to zoom and pan across multiple elements? Like an image with a few circles over it?
Groups and interactive elements
Let's add a red circle over our image.
<div class="container">
<svg id="map" width="100%" height="100%" style="background-color:red">
<image
id="image"
href="https://upload.wikimedia.org/wikipedia/commons/1/1e/Alexander_III_conquest_from_Issos_to_Babylon-fr.svg"
/>
<circle fill="red" cx="50" cy="50" r="25" />
</svg>
</div>
Now when we zoom and pan the image the circle always stays at the same place. In our case this circle may mark a place of a significant battle and we want it to be on the map. To fix this we should wrap our image with the circle inside a group g
.
<div class="container">
<svg id="map" width="100%" height="100%" style="background-color:red">
<!-- id="image" was moved to the group so that our JS code still works without changes -->
<g id="image">
<image
href="https://upload.wikimedia.org/wikipedia/commons/1/1e/Alexander_III_conquest_from_Issos_to_Babylon-fr.svg"
/>
<circle fill="red" cx="50" cy="50" r="25" />
</g>
</svg>
</div>
Now when we pan and zoom the image the circle stays at the same place on the map. Brilliant!
Window resizes
Still, there is a bug left. When you resize the window our scaling and panning brakes! To fix this we need to listen to resize
events on window and update our extent
properties. this is how our code would look like:
import * as d3 from "https://cdn.skypack.dev/d3@7.6.1";
// create a D3 selection of the SVG itself
const svg = d3.select("#map");
// create a D3 selection of the image element
const image = svg.selectChild("#image");
// calculate image dimensions once - they should not change
const { width, height } = image.node().getBoundingClientRect();
// ===========================================
const zoom = d3.zoom().on("zoom", zoomed);
function updateExtents() {
const { width: svgWidth, height: svgHeight } = svg
.node()
.getBoundingClientRect();
const minScale = Math.max(svgWidth / width, svgHeight / height);
zoom
// scale extent is how much you can zoom into or out of the image
.scaleExtent([minScale, 8])
// extent is mostly used to calculate things and make them smooth during zooming and panning
// by default it is the viewbox or width and height of the nearest SVG ancestor - this works for us
.extent([
[0, 0],
[svgWidth, svgHeight],
])
// translate extent is optional and is used to bound the viewport to the image
.translateExtent([
[0, 0],
[width, height],
]);
// apply calculated default scale
zoom.scaleTo(svg, minScale);
}
// ===========================================
// apply configured zoom behaviour to our svg
svg.call(zoom);
updateExtents();
window.addEventListener("resize", updateExtents);
function zoomed(event) {
const { transform } = event;
// apply calculated transform to the image
image.attr("transform", transform.toString());
}
Now resize your window and see that after a resize our translate and scale bounds still work properly.
Minimap
I think it would be quite interesting to implement a read-only minimap using the knowledge we just gained. Let's duplicate our image into a preview
group outside the image (so that id does not scale or pan).
<div class="container">
<svg id="map" width="100%" height="100%" style="background-color:red">
<g id="image">
<image
href="https://upload.wikimedia.org/wikipedia/commons/1/1e/Alexander_III_conquest_from_Issos_to_Babylon-fr.svg"
/>
<circle fill="red" cx="50" cy="50" r="25" />
</g>
<g id="preview" transform="translate(20,20) scale(0.1)">
<image
href="https://upload.wikimedia.org/wikipedia/commons/1/1e/Alexander_III_conquest_from_Issos_to_Babylon-fr.svg"
/>
<circle fill="red" cx="50" cy="50" r="25" />
<!-- here the minimap viewport will go -->
</g>
</svg>
</div>
Now we need to update the code to inject a viewport rectangle into the preview box.
I recommend you try think of a solution yourself before you go ahead!
const preview = svg.select("#preview");
const rect = preview
.append("rect")
.style("fill-opacity", "0")
.style("stroke", "red")
.style("stroke-width", "3px");
function updateViewport() {
const { width: svgWidth, height: svgHeight } = svg
.node()
.getBoundingClientRect();
rect.attr("width", svgWidth).attr("height", svgHeight);
}
// ===========================================
// apply configured zoom behaviour to our svg
svg.call(zoom);
updateExtents();
updateViewport();
window.addEventListener("resize", updateExtents);
window.addEventListener("resize", updateViewport);
Do not forget to update the width and height of the viewport on window resizes!
Next update the zoomed
function to transform both the image and the viewport. It is important to note that the transform we apply in this function is destined for the image. So it does not scale the viewport but the content. The trick is to invert the scale since the amount that the viewport scales is inversely proportial to how the content scales.
function zoomed(event) {
const { transform } = event;
// apply calculated transform to the image
image.attr("transform", transform.toString());
const scale = 1 / transform.k;
const inverseTransform = new d3.ZoomTransform(
scale,
-transform.x * scale,
-transform.y * scale
);
rect.attr("transform", inverseTransform.toString());
}
Profit! Now you have a minimap for your SVG map. Still we can go event further! How about allowing the user to click on a location on the minimap to jump to it?
Clickable minimap
To achieve that add a listener to click
event on preview
group. When clicked, calculate the position from the top left corner of the minimap. Then divide it by the scale of the minimap to get the position on the actual map. Finally, use this position to move the viewport.
Since we use the
translateTo
method it would not allow the user to clip out of bounds using the minimap since this method respects extent properties.
preview.on("click", handleMinimapClick);
const previewRect = preview.node().getBoundingClientRect();
// this scale is taken from HTML transform property above
const PREVIEW_SCALE = 0.1;
function handleMinimapClick(event) {
const dx = event.clientX - previewRect.x,
dy = event.clientY - previewRect.y;
const position = [dx / PREVIEW_SCALE, dy / PREVIEW_SCALE];
zoom.translateTo(svg, ...position);
}
Check it out! And you can still use the minimap pan around the actual map. If you do not want that call image.call(zoom)
instead of svg.call(zoom)
. Then zoom will only listen to clicking and dragging over the image but not the minimap.
Conclusion
Hope you found this tutorial useful! If you did, your like on the post would make my day, and if you did not I would appreciate your comment. I should note that you can use any SVG markup as the image
in this tutorial. So it is entirely and easily possible to have different points of interest on the image that you might make interactive with popups and etc.
Find the code on CodePen - https://codepen.io/yakovlev-alexey/pen/MWGqKER
Posted on October 6, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.