Introduction

In this tutorial, we will learn about the D3 transition to simulate animation on elements and D3 event listening to interact with the user's actions.

We will use the lines and scatter plot example from the previous tutorial and add to it in order to animate the paths and circles, as well as show different stages of the animation based on the user's interaction with a timeline and view a tooltip with information when the user's mouse hover over a circle in the scatter plot.

We will explain the methods that are involved in D3 animation and event listening as we add the code. You can explore these methods further on the D3 Documentations page. The resulting product should look like this page. Have a look and try to interact with the graph by pressing the play button, clicking on the timeline and hovering over circles in the scatterplot

Setting the HTML Document

Copy the following code and paste it into a new .html document using Sublime Text. It is the same code from the previous tutorial, except that we have added 10px to the height of the svg container to include the timeline that we will add later and hid the circles using opacity style .style("opacity", 0).

Paste the data file RentTrendByArea_Inner.csv from the previous tutorial in your current project file.


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Lines plot</title>
    <script src="https://d3js.org/d3.v4.min.js"></script>
    <style> /* set the CSS */
      .line {
        fill: none;
        stroke: #084594;
        stroke-width: 2px;
      }
      #title {
        background-color:#9ecae1;
        width: 960px;
        height: 45px;
        font-family: Helvetica, Arial, sans-serif;
        margin: 3px;
        padding:5px;
        margin-left: 10px;
      }
      #infovismain {
        background-color: #f7fbff;
        opacity: 1;
        width: 960px;
        height:520px;
        margin: 3px;
        padding: 5px;
        margin-left:10px;
      }
      #legendinfo {
        background-color:#9ecae1;
        width: 960px;
        height: 50px;
        font-family: Helvetica, Arial, sans-serif;
        margin: 3px;
        padding: 5px;
        margin-left: 10px;
      }
      #text {
        background-color: #f7fbff;
        opacity: 1;
        width: 960px;
        height:100%;
        margin: 3px;
        padding: 5px;
        margin-left:10px;
        font-family: Helvetica, Arial, sans-serif;
        text-align: justify;
        line-height: 150%;
      }
    </style>
  </head>
  <body>
    <div id="title">
      <h3>The reducing gap between incomes and rents: Inner Sydney's rent crisis</h3>
    </div>
    <div id="infovismain"></div>
    <script>
      // set the dimensions and margins of the graph
      var margin = {top: 20, right: 20, bottom: 50, left: 50};
      var width = 960 - margin.left - margin.right;
      var height = 500 - margin.top - margin.bottom;

      // append the svg object to the #infovismain div of the page
      // appends a 'group' element to 'svg'
      // moves the 'group' element to the top left margin
      var svg = d3.select("#infovismain").append("svg")
          .attr("width", width + margin.left + margin.right)
          .attr("height", height + margin.top + margin.bottom + 10)
        .append("g")
          .attr("transform",
                "translate(" + margin.left + "," + margin.top + ")");

      // Read the data from .csv file
      d3.csv("RentTrendByArea_Inner.csv", function(error, data) {
        if (error) {
          console.error(error);
        }

        // a function to parse the date / time
        var parseTime = d3.timeParse("%b-%y");
        // format the data
        data.forEach(function(d) {
            d.quarter = parseTime(d.quarter);
            d.rent = +d.rent;
            d.income = +d.income;
        });

        // set the Scales
        var xScale = d3.scaleTime()
                       .domain(d3.extent(data, function(d) { return d.quarter; }))
                       .range([0, width]);
        var yScale = d3.scaleLinear()
                       .domain([0, d3.max(data, function(d) { return Math.max(d.rent, d.income); })])
                       .range([height, 0]);

        // define the line generator
        var lineGenerator1 = d3.line()
            .x(function(d) { return xScale(d.quarter); })
            .y(function(d) { return yScale(d.rent); });

        var lineGenerator2 = d3.line()
          .x(function(d) {return xScale(d.quarter); })
          .y(function(d) {return yScale(d.income); }) 

        // Add paths
        var path1 = svg.append("path")
                       .data([data])
                       .attr("class", "line")
                       .attr("d", lineGenerator1)
                       .style("stroke", "blue");
        var path2 = svg.append("path")
                       .data([data])
                       .attr("class", "line")
                       .attr("d", lineGenerator2)
                       .style("stroke", "red");

        // Add circles
        var c1 = svg.append("g")
                          .attr("class", "c1");
        var c2 = svg.append("g")
                          .attr("class", "c2");
       
        var circles1 = c1.selectAll("circle")
                         .data(data)
                         .enter()
                         .append("circle")
                         .attr("class", "circles circles1")
                         .attr("cx", function(d) {
                            return xScale(d.quarter);
                         })
                         .attr("cy", function(d) {
                            return yScale(d.rent);
                         })
                         .attr("r", "3px")
                         .style("fill", "blue")
                         .style("opacity", 0);

        var circles2 = c2.selectAll("circle")
                         .data(data)
                         .enter()
                         .append("circle")
                         .attr("class", "circles circles2")
                         .attr("cx", function(d) {
                            return xScale(d.quarter);
                         })
                         .attr("cy", function(d) {
                            return yScale(d.income);
                         })
                         .attr("r", "3px")
                         .style("fill", "red")
                         .style("opacity", 0);

        // Add the X Axis
        svg.append("g")
            .attr("transform", "translate(0," + height + ")")
            .call(d3.axisBottom(xScale));

        // Add the Y Axis
        svg.append("g")
            .call(d3.axisLeft(yScale));

        // text label for the x axis
        svg.append("text")             
            .attr("transform",
                  "translate(" + (width/2) + " ," + 
                                 (height + margin.top + 20) + ")")
            .style("text-anchor", "middle")
            .text("Quarter")
            .style("font-family", "sans-serif");  

        // text label for the y axis
        svg.append("text")
            .attr("transform", "rotate(-90)")
            .attr("y", 0 - margin.left)
            .attr("x",0 - (height / 2))
            .attr("dy", "1em")
            .style("text-anchor", "middle")
            .text("Rent")
            .style("font-family", "sans-serif");  

      });
    </script>
    <div id="legendinfo">
      <p style="font-size:11px">
      Data source for income (red line): 6523.0, Household Income and Wealth, Australia, Equivalised disposable household incomes for NSW, Second income quintile mean incomes. Data before 1993 and after 2014 has been projected on the basis of 1993 and 2014 data, respectively.<br>
      Data source for rents (blue line): Housing New South Wales, Rent and Sales Reports. 
      </p>
    </div>
    <div id="text">
      <p>
      The chart above shows the progressively reducing gaps between lower middle incomes and rent trends in Inner Sydney. The red line shows income data trends over time for the second quintile of equivalised disposable household income in New South Wales. Equivalised disposable household income means the amount of income available to a household after taxes. The second quintile is the second lowest quintile of income as defined by the Australian Bureau of Statistics. The blue line shows the rental trends in inner Sydney from the Rent and Sales data provided by Housing NSW. The chart shows that over time there has been a progressive reduction of the gap between incomes and rents. The gap between the two lines provides an insight into disposable incomes: how much income is left over once rent is paid? The reducing gap shows a sharp reduction of disposable income left over after paying rent over time for the lower earning groups. For lower income earners living in inner Sydney, this implies reduced access to other benefits and amenities such as education, health or recreation. 
      </p>
    </div>
  </body>
</html>

				

Save the .html file and run it on a localhost. Check the result.

Drawing Timeline Components

We will add a background to the timeline using the SVG rect shape. Under the code that creates var circles2 , add the following lines:


// Creating timeline elements
// background
var timelineWidth = 900;
var time = svg.append("rect")
              .attr("id", "time")
              .attr("x", 0)
              .attr("y", height + margin.top + 25)
              .attr("width", timelineWidth)
              .attr("height", "10px")
              .style("fill", "#d4d8db");

				

Save the .html file and refresh the localhost page. You will see a rectangle added under the x-axis label Quarter.

Now, let's add a line element inside the rectangle. We will animate this line later to act as a timeline that indicates the phase of the animation:

              .attr("height", "10px")
              .style("fill", "#d4d8db");

// timeline
var timeline = svg.append("line")
                  .attr("id", "timeline")
                  .attr("x1", 0)
                  .attr("y1", height + margin.top + 30)
                  .attr("x2", 0)
                  .attr("y2", height + margin.top + 30)
                  .attr("stroke", "blue")
                  .attr("stroke-width", "4px");

				

In the code above, we used SVG line element and we assigned an id name to it and specified the coordinates of its first point x1, y1 and the coordinates of its second point x2, y2 as well as the colour and thickness of the line. Note here that we gave the same value to y1 and y2 because we want the length of the line to be zero. Later on, we will change its length during the animation.

We will add a green triangle shape that acts as a button. We will use it later to trigger the animation:

                  .attr("stroke", "blue")
                  .attr("stroke-width", "4px");

var playHeight = height + margin.top + 20;
        // Play button
        var play = svg.append("polygon")
                      .attr("id", "play")
                      .attr("points", "-25," + playHeight + " -25," + (playHeight + 20) + " -5," + (playHeight + 10))
                      .style("fill", "green");

				

In the code above, we created a variable playHeight to hold a value that represents the height of the triangle button. Then, we used SVG polygon element to draw the triangle. The polygon takes points as an attribute, to which we added the three coordinates of the triangle in the form: x1,y1 x2,y2 x3,y3 . Save the file and refresh the page to see the result.

Next, let's add an SVG text element that later will show a year value that changes based on the progress of the animation. For now, we assign a text value of 1990 using .text() method, 1990 represents the first year in our data quarter column.

                      .style("fill", "green");

// text to show the progress
var counterText = svg.append("text")
                     .attr("id", counterText)
                     .attr("x", 0)
                     .attr("y", playHeight - 5)
                     .text(1990);

				

Animation

Animation in D3 is done using transition method. This method manage to apply a change to the attributes and styles attr() and style() over a time, which we can specify by chainig the .duration(time) method. The location of the transition() method is chained before introducting the change in the attr() / style() values. It applies the change from the current attr() / style() value to the new attr() / style() value over a specific period of time to simulate an animation.

Note that transition can't apply to all types of attributes and styles including the case of .text() value. We will discuss this in details soon.

Back to our code! Let's add the following lines to animate path1 :

                     .text(1990);

// Find the length of each path
var totalLength1 = path1.node().getTotalLength();
var totalLength2 = path2.node().getTotalLength();
var transTime = 10000;
// A function to animate elements
function animate() {
  // Transit the path
  path1.attr("stroke-dasharray", totalLength1 + " " + totalLength1)
       .attr("stroke-dashoffset", totalLength1)
       .transition()
       .duration(transTime)
       .ease(d3.easeLinear)
       .attr("stroke-dashoffset", 0);
}
animate();

				

Check the result of the animation in the localhost page

We will follow similar steps to transit path2 :

       .attr("stroke-dashoffset", 0);

path2.attr("stroke-dasharray", totalLength2 + " " + totalLength2)
     .attr("stroke-dashoffset", totalLength2)
     .transition()
     .duration(transTime)
     .ease(d3.easeLinear)
     .attr("stroke-dashoffset", 0);

}
animate();
				

Check the results in the browser

While still inside the animate() function, let's add the code to transit the circles of the scatterplot:

     .attr("stroke-dashoffset", 0);

//Transit the circles
circles1.style("opacity", 0)
        .transition()
        .duration(500)
        .delay(transTime)
        .style("opacity", 1);
circles2.style("opacity", 0)
        .transition()
        .duration(500)
        .delay(transTime)
        .style("opacity", 1);

}
animate();
				

In the code above, we chained delay(delayTime) method to the transition() to postpone the transition a particular time delayTime . Notice that we assign a delay value transTime to the transition of circles, which is the same duration time of the path transition so that the circles only start to appear after the transition of the path is complete

opacity attribute has changed over the time of transition from fully invisible 0 to fully visible 1 .

Paste the following lines to animate the timeline:

        .style("opacity", 1);

// Transit the timeline
timeline.attr("x2", "0")
        .transition()
        .duration(transTime)
        .ease(d3.easeLinear)
        .attr("x2", timelineWidth);

}
animate();
				

In the code above, we transit the timeline line so that the x position of its second point x2 change to timelineWidth = 900 at the end of the transition. The transition duration here is the same as the duration for the path element so that both transitions will end at the same time and the timeline change in length will represent the progress of the animation for the paths and other elements in the graph

The last transition will be applied to the SVG text item counterText , which need to change as the animation takes part to represent the current year. Remember that we said earlier that the transition might not work on everything including the value of text(). When we apply it to text() the value of the text will change directly to the text value assigned after the transition method without showing the transition in the text in time. Luckily, there is a walk around using tween method. Paste the following lines and let's discuss them in details:

        .attr("x2", timelineWidth);

// Transit the counter
counterText.transition()
           .duration(transTime)
           .ease(d3.easeLinear)
           .tween("text", function() {
              var currentValue = +counterText.text();
              var interpolator = d3.interpolateRound(currentValue, 2016);
              return function(t) {
                counterText.text(interpolator(t));
              }
           });

}
animate();
				

We applied the transition methods as we used to do and then, we chained to tween() method. The applied concept is:


           .tween("aspect", factoryFunction() {
           		// Access the current value of the aspect
           		// add an interpolate 
              return function(t) {
                // this function will iterate a number of times based on the tansition duration to produce values (t) between 0 and 1
              }
           });

				

tween method takes two arguments; the first is the aspect that we want to change (in this case is text) and the second is known as a factoryFunction, which returns another function that takes an attribute t and iterates through the code inside it a number of times depending on the duration of the transition. During this iteration, the value of t change from 0 to 1

Check the animation by refreshing the localhost page.

Event Listening

Events are actions done by the user to interact with the page. These include mouse interactions; click, hover, zoom, move and other mouse actions, screen touch, keyboard input, changing the screen size and other types of interactions. D3 can listen to these actions and execute a function when a particular event happens. The syntax is:


d3selection.on("eventName", function());

				

When an event apply to the selected element (mouse click for example), the function will be executed. There are different types of events that you can specify, including click , dblclick , mouseover , mouseout and others. We will add a number of events listening to our elements in the graph. Let's start by adding one to the triangle play button that we created earlier so that when a user clicks the button, the animate() function that we created earlier will be called to trigger the animation:

                counterText.text(interpolator(t));
              }
           });
}
animate();

// Handle clicking play button
play.on("click", function() {
  d3.select(this).transition().duration(200)
    .attr("transform", "translate(1, 1)")
    .transition().duration(200)
    .attr("transform", "translate(-1, -1)");
  reset();
  animate();
});
// A function to reset all transitions 
function reset() {
  path1.transition().duration(0)
       .attr("stroke-dasharray", totalLength1 + " " + totalLength1)
       .attr("stroke-dashoffset", totalLength1);
  path2.transition().duration(0)
       .attr("stroke-dasharray", totalLength2 + " " + totalLength2)
       .attr("stroke-dashoffset", totalLength2);
  circles1.transition().duration(0)
          .style("opacity", "0");
  circles2.transition().duration(0)
          .style("opacity", "0");
  timeline.transition().duration(0)
          .attr("x2", 0);
  counterText.transition().duration(0)
          .text("1990");
}

				

Next, we add an event listener to the timeline so that when a user clicks on a point in the timeline, the elements transit to values that correspond to the point's position on the timeline.

The concept is simple. First, we need to find out the percentage of the timeline length at the click point. We can calculate this by dividing the x position of the mouse in relation to the timeline by the total length of the timeline; this percentage will help to calculate the current transition values of the attributes of the moving elements:

          .text("1990");
}

// Handle clicking the timeline
d3.selectAll("#time, #timeline").on("click", function() {
  var coordinates = d3.mouse(time.node());
  var passed = coordinates[0]/timelineWidth;
  reset();
  timeline.transition().duration(0)
          .attr("x2", coordinates[0] + "px");
  path1.transition().duration(0)
       .attr("stroke-dashoffset", totalLength1 - (passed * totalLength1));
  path2.transition().duration(0)
       .attr("stroke-dashoffset", totalLength2 - (passed * totalLength2));
  if(passed > 0.99) {
    circles1.transition().duration(0)
          .style("opacity", "1");
    circles2.transition().duration(0)
            .style("opacity", "1");
  }
  counterText.transition().duration(0)
             .text(Math.ceil(1990 + passed * (2016 - 1990)));
});

				

Next, we will add an event listener to the circles of the scatter plot to show a tooltip whenever a user's mouse hovers over a circle in the scatter plot. The idea is also simple. First, we create an HTML div to be the container of the tooltip. We hide this container using CSS code and make it visible when a mouse hovers over a circle. Then, we use the position of the mouse to locate the div element.

Let's add a div element in the HTML document under the code line that defines the <div id="infovismain"></div> and then add a p element inside the new div:

<body>
  <div id="title">
    <h3>The reducing gap between incomes and rents: Inner Sydney's rent crisis</h3>
  </div>
  <div id="infovismain"></div>

  <div id="tooltip">
    <p id="tooltip-text">
    </p>
  </div>

				

Change the CSS style of the two elements:


  #tooltip {
    position: fixed;
    width: 200px;
    margin: 0;
    padding: 3px;
    border-radius: 3px;
    background-color: #ced4db;
    visibility: hidden;
  }
  #tooltip-text {
    margin: 0;
    padding: 0;
  }
  .bold {
    font-weight: bold;
  }

				

Note that we change the visibility property of the div to hidden. We also change the font-weight of the class bold , which we will use soon. Now, back to the Javascript code, add the lines:

             .text(Math.ceil(1990 + passed * (2016 - 1990)));
});

// Handle mouseover circles
d3.selectAll(".circles1").on("mouseover", function() {
  d3.select(this).attr("r", "15px");
  var qu = d3.select(this).data()[0].quarter;
  var formatTime = d3.timeFormat("%B, %Y");
  var qu = formatTime(qu);
  var re = d3.select(this).data()[0].rent; 
  var screenWidth = d3.select("html").node().getBoundingClientRect().width;
  var xPos = d3.event.clientX;
  var yPos = d3.event.clientY + 20;
  d3.select("#tooltip")
    .style("left", function() {
      if(200 + xPos >= screenWidth) {
        return (xPos - 200) + "px";
      } 
      else {
        return xPos + "px";
      }
    })
    .style("top", yPos + "px")
    .transition()
    .style("visibility", "visible");
  d3.select("#tooltip-text").html("<span class=bold>Quarter</span>: " + qu + "<br/><span class=bold>Rent</span>: $" + re);
});

				

In the code above, we:

Similarly, add the following code to handle the mouseover event of circles2 :

  d3.select("#tooltip-text").html("<span class=bold>Quarter</span>: " + qu + "<br/><span class=bold>Rent</span>: $" + re);
});

d3.selectAll(".circles2").on("mouseover", function() {
  d3.select(this).attr("r", "15px");
  var qu = d3.select(this).data()[0].quarter;
  var formatTime = d3.timeFormat("%B, %Y");
  var qu = formatTime(qu);
  var inc = d3.select(this).data()[0].income; 
  var screenWidth = d3.select("html").node().getBoundingClientRect().width;
  var xPos = d3.event.clientX;
  var yPos = d3.event.clientY + 20;
  d3.select("#tooltip")
    .style("left", function() {
      if(200 + xPos >= screenWidth) {
        return (xPos - 200) + "px";
      } 
      else {
        return xPos + "px";
      }
    })
    .style("top", yPos + "px")
    .transition()
    .style("visibility", "visible");
  d3.select("#tooltip-text").html("<span class=bold>Quarter</span>: " + qu + "<br/><span class=bold>Income</span>: $" + inc);
});

				

If you check the result in the localhost page and hover your mouse over circles in the scatter plot, you will find that the tooltip doesn't disappear when you move your mouse away. To fix this, we will add mouseout event to reset the radius of the circle and make the tooltip hidden when a user moves the mouse away from the circle:

  d3.select("#tooltip-text").html("<span class=bold>Quarter</span>: " + qu + "<br/><span class=bold>Income</span>: $" + inc);
});

// Handle mouseout circles
d3.selectAll(".circles1").on("mouseout", function() {
  d3.select(this).attr("r", "3px");
  d3.select("#tooltip")
    .transition()
    .style("visibility", "hidden");
});
d3.selectAll(".circles2").on("mouseout", function() {
  d3.select(this).attr("r", "3px");
  d3.select("#tooltip")
    .transition()
    .style("visibility", "hidden");
});

				

Check the result in the localhost page. Everything should be running smoothly by now.

This is the end of the tutorial. By now, you are expected to be familiar with the concept of transition and event listening in D3. They are prowerful tools to create interactive graphs in D3.


Sarkar, S. and Hussein, D.A., 2017, D3 Tutorials for Information Visualisation Design Studio, University of Sydney.
Email: somwrita.sarkar@sydney.edu.au, dhus4848@uni.sydney.edu.au