Scalable Vector Graphics (SVG) are create to display high quality, scalable, graphics on the web. Most graphics software like Adobe Illustrator or Inkscape can export it. The graphics are of course static, but with a little help from the JavaScript data visualization library d3.js, they can be brought to life by animating parts of them or making some elements respond to actions like mouse clicks.
In this post I will explain how to do that using the example of an interactive map for the LATINNO project.
Demonstration and code
You can have a look at the finished example and check out the complete code there. Please note that this uses d3.js version 4.
Anatomy of an SVG file
SVG is an XML-based format, so you can easily peak inside the file and will see things like these:
We can see that the root element is the svg
-element and inside of this, there are several element groups (denoted by the g
-element) containing geometric shapes like rectangles or paths. On w3schools.com there’s a good tutorial explaining the base elements of SVG. In our example, we have a map with lots of squares that depict South and Latin America. Each of these squares is contained in a g
-element as a rect
shape.
In the svg
-element, there are several attributes defining the overall display of the graphics file. The default display dimensions and a “view box” are defined. We will come to that later.
What we also see that this file was apparently created using Adobe Illustrator. Having a sharper look at the “translate” commands and x / y coordinates, we see that the placement of the elements on the 2D coordinate space seems rather chaotic. For example, the first group of elements is moved to a point (143.58…,124.70…), then the first rectangle is placed at (-186.616, -74.369) in relation to the group’s translation. The same goes with the path
-element. The coordinates are in no way normalized and lots of absolute positioning is used. Unfortunately, this is how graphics programs often export files. We’ll see how to handle the problems that arise from that later.
Displaying an SVG in a responsive way
Normally, you can embed an SVG image in HTML just like any other image using the img
tag. This way, you can also easily apply CSS options to automatically scale the image for correct fitting on all kinds of screens. But when we want to interact with the SVG file using d3.js, we will need to load it as XML and attach it to an svg
tag inside the HTML. We want the SVG element to appear on the left side of some text and want it to scale correctly for different screen sizes. So at first we will need to define our HTML body like this:
Setting the width to 100% for the svg
-element will make sure it scales with the #map_container
element. For the right scaling behavior of the parent elements, we define the following CSS:
#main {
width: 100%;
}
#map_container {
width: 50%;
max-width: 530px;
float: left;
}
#some_content {
float: left;
padding: 2em;
}
We will make a small modification to our SVG file and add a group with the ID “maproot” as base group for all elements (see this gist). This will make things easier later on as it allows us to directly select all elements inside our SVG file.
Now it’s time to load the SVG. d3 has a function called xml()
with which we can load any XML file. It passes the loaded XML DOM tree as second argument. We will get the previously inserted #maproot
group element and insert it into the (previously empty) svg
-element in our HTML file. We will also define two d3 objects for later use to get access to the svg
and the #maproot
element:
var svg = null;
var maproot = null;
d3.xml("map.svg", function(error, xml) {
if (error) throw error;
// "xml" is the XML DOM tree
var htmlSVG = document.getElementById('map'); // the svg-element in our HTML file
// append the "maproot" group to the svg-element in our HTML file
htmlSVG.appendChild(xml.documentElement.getElementById('maproot'));
// d3 objects for later use
svg = d3.select(htmlSVG);
maproot = svg.select('#maproot');
// get the svg-element from the original SVG file
var xmlSVG = d3.select(xml.getElementsByTagName('svg')[0]);
// copy its "viewBox" attribute to the svg element in our HTML file
svg.attr('viewBox', xmlSVG.attr('viewBox'));
});
In the last four lines, we also copied the viewBox
attribute from the original “map.svg” file to the SVG element in the HTML file. This is important as it defines the viewport, i.e. base display transformation for this graphics (in our example, Illustrator defined an offset of (-50, 50) as base display transformation). When we don’t do this, the displayed graphics will be wrongly placed or skewed.
Selecting elements and animating them
As already noted, the loaded SVG file is represented as DOM tree and hence we can work with it just like with any other HTML document. This means that we can access certain elements in the DOM tree using d3’s selection mechanism. As already noted, our map contains lots of squares, each in its own group (g
-element). Some of these squares represent a country and are colored in a specific way. These squares have been annotated manually in order to select them via d3.js to make them “dynamic”. So each of these square’s g
-element got two class attributes, one named “country” and the other named with the country’s specific label, for example class="country mexico"
. After these annotations are made to the SVG file, we can easily get all country squares by selecting all children of the “maproot” group that are a group with the class “country”:
var countrySquares = maproot.selectAll('g.country');
We can play around with this by looping through all country groups, selecting their actual square shape (depicted by the path
-element), and setting the “fill” style of each shape to “blue”:
countrySquares.each(function() {
d3.select(this).select('path').style('fill', 'blue')
});
When calling the each()
function on a d3.js selection, the passed function is evaluated on each element. The special variable this
contains the DOM node of the respective element. In order to use d3.js specific functions like select()
on it, we need to turn it into a d3.js-object first by calling d3.select(this)
.
Now that we’re able to select the country squares, we can play an animation with each of it. Our goal is to set up a very subtle animation in order to highlight the country squares so that people see that they are interactive. Each country square should slowly shine brighter and then transition back to its original color. This endless loop should start at a random point in time for each square so that the animation looks “natural”.
The key to this kind of animations is d3’s “transitions” system. It allows to gradually change a certain property of an element (e.g. its color, position, size) in a certain amount of time. For example, instead of turning all country squares blue immediately, we could also make a soft transition from their current color to red within 1000ms like this:
countrySquares.each(function() {
d3.select(this).select('path')
.transition().duration(1000)
.style('fill', 'red')
});
We can use this now in order to write an animation function that will fade each country square to a brighter color, then fade back to its initial color and then restart the whole animation again in order to make it run endlessly. The initial colors for each country have been recorded before in a countryColors
object. We make two chained transitions t0
and t1
, each having a duration of 1 second. On the “end” event of t1
, the whole function is started again, creating an endless loop:
function animateSquare() {
var squarePath = d3.select(this);
var countryLabel = this.parentNode.classList[1]; // the country's label is the second class name
var initialColor = countryColors[countryLabel]; // get its previously recorded initial color
var fadeToColor = initialColor.brighter(); // define the "bright" color that we want to fade to
// t0 fades from the initial color to the brighter color
var t0 = squarePath.transition().duration(1000);
t0.style("fill", fadeToColor);
// t1 fades from the brighter color to the initial color
var t1 = t0.transition().duration(1000);
t1.style("fill", initialColor);
// restart the animation
t1.on('end', animateSquare);
}
Now in order for the animation to start randomly, we write an initialization function in which we loop through all country squares, get a random delay between 0 and 1000 in milliseconds and then schedule the execution of the animateSquare()
function using d3’s .transition().delay()
function:
function init() {
countrySquares = maproot.selectAll('g.country');
// start the animation for each country square after random delay
countrySquares.each(function () {
var countrySq = d3.select(this);
if (countrySq.property('classList').length < 2) { // we need 2 classes: "country" and the specific country label
return;
}
// the country label is the second class name
var countryLabel = countrySq.property("classList")[1];
// the country path is the shape that is displayed
var countryPath = countrySq.select("path");
countryColors[countryLabel] = d3.color(countryPath.style("fill")); // record its initial color
// get a random delay between 0 and 1000 in milliseconds
var initialDelay = (Math.random() * 1000 | 0);
// schedule the animation randomly
countryPath.transition()
.delay(initialDelay) // set the initial delay
.on("start", animateSquare); // after this delay start the animation
});
}
Interaction
When we call init()
after loading the SVG file, the country squares will now have a subtle blinking effect. The next task is to make them interactive. For this, they must react on events such as clicks or mouse-overs. Let’s define a very simple callback function that will be executed each time the mouse pointer hovers over a country square:
function countrySelectedAction() {
var label = this.parentNode.classList[1];
console.log('country selected: ', label);
}
We will attach an event listener to each path
element of a country square group. Hence this
refers to the respective path
element and this.parentNode
to the group of the country square (which has the country label in its class list). We can set the callback in the init()
function on each country’s path
like this:
function init() {
countrySquares = maproot.selectAll('g.country');
// start the animation for each country square after random delay
countrySquares.each(function () {
// ...
var countryPath = countrySq.select("path");
// set the callback
countryPath.on('mouseover', countrySelectedAction);
// ...
}
Now we can see the respective output on the console when the mouse pointer is above a country square. When we actually want to display something next to a country, we would usually add an element to the country square group. However, the positioning of the country square group and its contained path shape is so messed up by the Illustrator SVG export, that this is hardly possible. It’s easier to find out the country square path’s position in relation to the base group (the “maproot” group) and then add an element to the “maproot” group with the exact same position. In order to achieve this, we can use the getTransformToElement() function that each SVG element should have. However, Chrome 48+ has a bug that makes it necessary to add the following workaround function:
SVGElement.prototype.getTransformToElement = SVGElement.prototype.getTransformToElement || function(elem) {
return elem.getScreenCTM().inverse().multiply(this.getScreenCTM());
};
Then we can calculate the exact center position of a country square path element as follows:
- Calculate its transform matrix
gtrans
, which is the transformation of the path’s parent group to the “maproot” group - Add the e and f properties of
gtrans
that denote the (unscaled) X and Y with the path’s bounding box position (bbox.x
andbbox.y
) in order to get to the path’s top left position. - Add the half of the bounding box dimensions in order to get to the center of the path element.
var bbox = this.getBBox();
var gtrans = this.getTransformToElement(document.getElementById('maproot'));
// position in relation to maproot group
var x = gtrans.e + bbox.x + bbox.width / 2;
var y = gtrans.f + bbox.y + bbox.height / 2;
In order to test this, we can append a small yellow circle to the “maproot” group at the country square’s position in our callback function:
function countrySelectedAction() {
var label = this.parentNode.classList[1];
var bbox = this.getBBox();
var gtrans = this.getTransformToElement(document.getElementById('maproot'));
// position in relation to maproot group
var x = gtrans.e + bbox.x + bbox.width / 2;
var y = gtrans.f + bbox.y + bbox.height / 2;
curSelection = maproot.append('circle')
.attr('class', 'circle ' + label)
.attr('r', 1)
.attr('cx', x)
.attr('cy', y)
.style('fill', 'yellow');
}
A reset function is called on “mouseout” and removes the circle again:
function init() {
countrySquares = maproot.selectAll('g.country');
// start the animation for each country square after random delay
countrySquares.each(function () {
// ...
var countryPath = countrySq.select("path");
// set the callback
countryPath.on('mouseover', countrySelectedAction);
countryPath.on('mouseout', resetCountrySelection);
// ...
}
function resetCountrySelection() {
if (curSelection !== null) {
curSelection.remove();
}
curSelection = null;
}
That’s basically it. Now that we know how to place elements next to such a square, we can show further information for each country, as done for example on the LATINNO website.
Recent Comments