Lecture 3 - iloughman/D3-Lecture-Project GitHub Wiki
Intro
In this tutorial, we will provide an introduction to D3 transitions.
The Transition Life Cycle
Transitions are a special type of selection where the operators apply smoothly over time. The operators, in this case, are interpolating functions that affect, most often, attributes and styles of DOM elements.
Consider the following example:
var circle = svg.append('circle')
.attr('cx', 20)
.attr('cy', 20)
.attr('r', 10)
circle.transition()
.duration(3000)
.delay(1000)
.ease('linear')
.attr('cx', 300)
.attr('cy', 300)
.attr('r', 20)
In the code above a circle is created and assigned (as a selection) to the variable circle
.
From this selection a transition is scheduled by running circle.transition()
. When the page loads, the transition will start (almost) immediately because we haven't created a DOM event to trigger the transition. Often, the start of a transition will be more easily distinguished from it's scheduling.
After the transition begins, it runs for a certain period of time. During this period of time an interpolation variable t ranges from 0 to 1. In this example, the circle will transition from its starting attribute values (defined when it was appended to the SVG) to its final attribute values (defined after the transition is scheduled). The transition ends when t reaches 1.
Let's look at this particular transition more closely. We can customize the duration and delay of the transition by calling those respective methods. duration(3000)
sets the length of the transition (while it is running) to three seconds, and delay(1000)
delays the running of the transition by one second from the time it starts.
The easing of a transition is more complicated. This value controls the interpolation of the transitioning values. In other words, how do the values that are changing during the transition respond to the changing value of t. Maybe they change quickly to begin with but then slow down. Maybe vis-versa. Maybe they change at a constant rate. We will discuss this more later.
Selections
In order to use transitions effectively, it is important to have a strong understanding of how D3 selections work. In particular, when transitioning between datasets that have different numbers of elements, D3 selections allow for the appending and removal of data elements.
Consider the following selection:
<body>
<p>One</p>
<p>Two</p>
<p>Three</p>
<script>
var dataset = ['New One', 'New Two', 'New Three']
var paragraphs = d3.select('body').selectAll('p')
.data(dataset)
paragraphs.html(function(d){return d})
</script>
<body>
In this example, the paragraphs
variable is referred to as the update selection. It tell us how the set of paragraph elements that already exist in the DOM overlap with set of data we want to bind to those elements. In this case, the three paragraph elements match exactly with the three pieces of data in the array. Without any additional adjustments the data is bound to those three elements, and we can use that data to update the html in each paragraph element.
Let's adjust the dataset slightly.
var dataset = ['New One', 'New Two', 'New Three', 'New Four']
Now, the set of data is larger than the set of pre-existing paragraph elements. The update selection knows about this. In particular, chaining the enter()
method off of the update selection allows us to register the additional datum and bind it to a paragraph element that previously did not exist.
More precisely:
var dataset = ['New One', 'New Two', 'New Three']
var paragraphs = d3.select('body').selectAll('p')
.data(dataset)
paragraphs.enter().append('p')
paragraphs.html(function(d){return d})
The paragraphs selection now contains four paragraph elements!
A similar procedure is necessary if the set of data is smaller than the pre-existing element selection.
A more thorough discussion can be found here
###Two Examples
The remainder of this tutorial will discuss two examples of transitions. The goal is to provide some guidance on how to create useful transitions for data visualizations. There are an abundance of examples throughout the D3 documentation, but hopefully these two examples will prove instructive.
Bar Charts
Creating a meaningless bar chart is fairly straightforward.
var width = 600;
var height = 250;
var svg = d3.select("body")
.append("svg")
.attr("width", width)
.attr("height", height);
var dataset = d3.range(20).map(function(d){
return Math.floor(Math.random()*25)+5
})
var xScale = d3.scale.ordinal()
.domain(d3.range(dataset.length))
.rangeRoundBands([0, width], 0.3);
var yScale = d3.scale.linear()
.domain([0, d3.max(dataset)])
.range([0, height]);
svg.selectAll("rect")
.data(dataset)
.enter()
.append("rect")
.attr('class','bar')
.attr("x", function(d, i) {
return xScale(i); // Assigns x coordinate px position
})
.attr("y", function(d) {
return height - yScale(d);
})
.attr("width", xScale.rangeBand()) // Width =
.attr("height", function(d) {
return yScale(d);
})
.attr("fill", function(d) {
return "rgb(0,"+(d*10)+", " + (d * 10) + ")";
});
In this example the dataset is a 20 element array, where each value represents the height (after scaling) of the corresponding bar.
Transitioning from one dataset to another dataset of the same size is surprisingly straightforward. Let's create a click event to trigger this transition.
d3.select('div.same')
.on('click', function(){
dataset = dataset.map(function(d){
return Math.floor(Math.random()*25)+5
})
svg.selectAll('rect')
.data(dataset)
.transition()
.duration(2000)
.attr('y', function(d){
return height - yScale(d)
})
.attr('height', function(d){
return yScale(d)
})
.attr("fill", function(d) {
return "rgb(0, 0, " + (d * 10) + ")";
})
})
Here, the click event is registered on a <div>
with class='same'
. Try it out!
Now consider how to approach adding a larger dataset. We need to adjust several components in order to be able to smoothly move an additional rectangle into our bar chart.
Let's first create a random dataset one entry bigger than the previous one.
dataset = dataset.map(function(d){return Math.floor(Math.random()*25)+5})
dataset.push(Math.floor(Math.random()*25)+5)
The first thing to adjust is our scaling function.
xScale.domain(d3.range(dataset.length))
Next, let's update the data bound to the existing rectangles and also enter the one additional rectangle.
var bars = svg.selectAll('rect')
.data(dataset)
bars.enter().append('rect')
.attr('x', width)
.attr('y', function(d){
return height - yScale(d)
})
.attr('width', xScale.rangeBand())
.attr('height', function(d){
return yScale(d)
})
.attr('fill', function(d){
return "rgb(" + (d * 10) + ",0,0)"
})
Here, the variable bars
represents the update selection, from which we can enter our additional rectangle.
Finally, we schedule the transition inside a click event. Including the previous snippets of code, the entire transition appears below:
d3.select('div.larger')
.on('click', function(){
dataset = dataset.map(function(d){return Math.floor(Math.random()*25)+5})
dataset.push(Math.floor(Math.random()*25)+5)
xScale.domain(d3.range(dataset.length))
var bars = svg.selectAll('rect')
.data(dataset)
bars.enter().append('rect')
.attr('x', width)
.attr('y', function(d){
return height - yScale(d)
})
.attr('width', xScale.rangeBand())
.attr('height', function(d){
return yScale(d)
})
.attr('fill', function(d){
return "rgb(" + (d * 10) + ",0,0)"
})
bars.transition()
.duration(2000)
.attr('x', function(d,i){
return xScale(i)
})
.attr('y', function(d){
return height - yScale(d)
})
.attr('width', xScale.rangeBand())
.attr('height', function(d){
return yScale(d)
})
.attr('fill', function(d){
return "rgb(" + (d * 10) + ",0,0)"
})
})
Next, let's use transitions to sort our data. This is surprisingly simple.
d3.select('div.sort')
.on('click', function(){
svg.selectAll('rect')
.sort(function(a,b){return b-a})
.transition()
.delay(function(d,i){
return i*200;
})
.attr('x', function(d,i){
return xScale(i)
})
})
When we use the selection.sort()
method we have access not to the elements themselves in the sort comparator, but to the data bound to those elements. This is extremely convenient. It allows use to reorder the selection array based on the data bound to the elements in that array.
After we sort the selection we schedule a transition that will shift the rectangle elements to their new position. The elements will shift in succession because each one is assigned an delay of an increasing increment.
Bar Charts - Scrabble
Below is a more complicated bar chart example. Try to walk through the sort transition yourself.
var width = 600;
var height = 250;
var svg2 = d3.select("body")
.append("svg")
.attr("width", width)
.attr("height", height+100);
// Scrabble tile values
var data = [{letter:'A',value:1},
{letter:'B',value:3},
{letter:'C', value:3},
{letter:'D', value:2},
{letter:'E', value:1},
{letter:'F', value:4},
{letter:'G', value:2},
{letter:'H', value:4},
{letter:'I', value:1},
{letter:'J', value:8},
{letter:'K', value:5},
{letter:'L', value:7},
{letter:'M', value:3},
{letter:'N', value:1},
{letter:'O', value:1},
{letter:'P', value:3},
{letter:'Q', value:10},
{letter:'R', value:1},
{letter:'S', value:1},
{letter:'T', value:1},
{letter:'U', value:1},
{letter:'V', value:4},
{letter:'W', value:4},
{letter:'X', value:8},
{letter:'Y', value:4},
{letter:'Z', value:10}]
var xScale2 = d3.scale.ordinal()
.domain(d3.range(dataset.length))
.rangeRoundBands([0, width], 0.3);
var yScale2 = d3.scale.linear()
.domain([0, d3.max(dataset)])
.range([0, height]);
var xScale2 = d3.scale.ordinal()
.domain(data.map(function(d){return d.letter}))
.rangeRoundBands([0, width], 0.2)
var yScale2 = d3.scale.linear()
.domain([0, d3.max(data,function(d){return d.value})])
.range([0,height])
var xAxis = d3.svg.axis()
.scale(xScale2)
.orient('bottom')
svg2.selectAll("rect")
.data(data)
.enter()
.append("rect")
.attr('class','bar')
.attr("x", function(d, i) {
return xScale2(d.letter); // Assigns x coordinate px position
})
.attr("y", function(d) {
return height - yScale2(d.value);
})
.attr("width", xScale2.rangeBand()) // Width =
.attr("height", function(d) {
return yScale2(d.value);
})
.attr("fill", function(d) {
return "rgb(0,"+(d.value*30)+", " + (d.value * 50) + ")";
});
svg2.append('g')
.attr('class','x axis')
.attr('transform', 'translate(0,'+(height+10)+')')
.call(xAxis)
// Create labels
svg2.selectAll("text")
.data(dataset)
.enter()
.append("text")
.text(function(d) {
return d;
})
.attr("text-anchor", "middle")
.attr("x", function(d, i) {
return xScale2(i) + xScale2.rangeBand() / 2;
})
.attr("y", function(d) {
return height - yScale2(d) + 14;
})
.attr("font-family", "sans-serif")
.attr("font-size", "11px")
.attr("fill", "white");
d3.select('div.sortScrabble')
.on('click', function(){
xScale2.domain(data.sort(function(a,b){return b.value-a.value}).map(function(d){return d.letter}))
console.log(data.sort(function(a,b){return b.value-a.value}).map(function(d){return d.letter}))
svg2.selectAll('rect')
.sort(function(a,b){return b.value-a.value})
.transition()
.delay(function(d,i){
return i*50
})
.attr('x', function(d,i){
console.log(xScale2(d.letter))
return xScale2(d.letter)
})
svg2.select('.x.axis')
.transition()
.call(xAxis)
.selectAll('g')
.delay(function(d,i){
return i*50
})
})
Custom Transitions - Tween Functions
In all of the examples so far, D3 has created the transition for us. In other words, the mapping between the interpolation variable t and the transitioning feature has been created for us. In certain situations, either we want more control over this map, or D3 does not have a built in transition for the specific attribute or feature.
Consider the code:
var width = 600;
var height = 250;
var data = [6,7,8,9,10]
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height+100);
var text = svg.selectAll('text')
.data(data)
.enter().append('text')
.text(function(d){return d})
.attr( 'x', 150 )
.attr( 'y', function(d,i){return i*25+20} )
.attr("font-size", "20px")
text.on('click', function(d){
d3.select(this)
.transition()
.text(15)
})
We attach a click event to each text element scheduling a transition that sets the text to 10. At first glance we might expect the clicked value to transition to 15 in a smooth way, meaning that it would count up to 15, since we know that transitions function through interpolation.
Unfortunately, this is not the case. Even though our values are numbers in this case, it would be easy to change them to strings. Then what would it mean to interpolate between two strings. Certainly there are ways to define such an interpolation. But D3 does not come with one such interpolation. Instead, we will write our own.
Tweens
In order to create this interpolation we must define a tween function. A tween function is a function that returns an interpolator, which takes the interpolation variable t and maps it to the appropriate value (e.g. attribute, style, text, etc).
For the text example above, an appropriate tween function is written below.
var textTween = function(d,i){
var currentValue = +this.textContent
return function(t){
this.textContent = (1-t)*currentValue + 15*t;
}
}
return textTween
Notice that the interpolation function returned by textTween
sets this.textContent
rather than returning a specific value. In this example, this
refers to the specific html element that has been clicked. (Note: when using the specific tween functions attrTween and styleTween
the interpolator function returns a value that assigns its value to the specified attribute or style).
Once we've written our tween function, using it is straightforward.
text.on('click', function(d){
d3.select(this)
.transition()
.duration(2000)
.ease('text',wrapperTween(15))
})
This example was created for instruction. In what situations would we want to write our own tween functions rather than use those that D3 provides for us?
One answer is for transitioning paths.
Path Transitions
One of the most common data visualizations for time series data is a line plot. You can refer back to the example here.
Let's animate this line plot using transitions. We need to create a function that will interpolate the sequence of data points that represents the line. First, for convenience, lets look at the line function:
var lineFunction = d3.svg.line()
.x(function(d){return xScale(d.date)})
.y(function(d){return yScale(d.capacity)})
.interpolate('monotone')
Recall that this function determines how we assign data values (or data coordinates) to SVG coordinates. The scale functions are straightforward.
var xScale = d3.time.scale()
.domain(d3.extent(table, function(d){return d.date}))
.range([0, 600])
var yScale = d3.scale.linear()
.domain([0, d3.max(table, function(d){return d.capacity})])
.range([height, 0])
Previously, we set our path data simply by invoking the line function with the table data:
svg.append('path')
.attr('class','line')
.attr('d', lineFactory(table))
To transition (or animate) our path data into view, we use an attribute tween function.
var lineTween = function(){
var map = d3.scale.quantile()
.domain([0,1])
.range(d3.range(1,table.length))
var interpolator = function(t){
var lineSubset = table.slice(0,map(t))
return lineFunction(lineSubset)
}
return interpolator;
}
svg.append('path')
.attr('class','line')
.transition()
.duration(3000)
.attrTween('d', lineTween)
Let's walk through each line of the line tween function. The map function uses a D3 quantile scale to create a function that maps the continuous interval 0 to 1 (think interpolation variable t) to a discrete set from 1 to the length of the table array. Combining this with table.slice(0,map(t))
provides a map from the interval [0,1]
to a subsequence of coordinates from our table data. The interpolator function takes this subsequence and applies the line function to it, which returns the appropriate path string (i.e. the string that the d
attribute must be set to).
Running this script will animate the path of the line plot.