D3.js provides various tools to support interactive data visualization, among which d3.transition
makes it possible to add animations to images simply and efficiently.
From an API perspective, d3.transition
is very straightforward, similar to jQuery. However, to design ideal animation effects, one must mention a core concept of D3's graphic rendering: the General Update Pattern
. The core and implementation of D3's data-driven characteristics rely on this pattern, and naturally, animations and interactions must start from it.
Not all graphics must follow the Update Pattern, such as one-time drawings or static graphics without interaction. However, when dealing with dynamic data, this Update Pattern not only facilitates writing maintainable code but also better leverages D3's powerful capabilities.
General Update Pattern#
D3's data-driven model is illustrated in the above image. When using d3.data()
to bind a data Array
to DOM elements, there are three stages between data and elements:
-
Enter: Existing data, but the corresponding DOM has not yet been created.
-
Update: Data elements are bound to DOM elements.
-
Exit: Data elements have been deleted, but the DOM elements still exist, meaning the DOM has lost its bound elements.
Regarding this point, no detailed elaboration will be made here; please refer to the documentation. Here, we will directly introduce the General Update Pattern
for versions V4 and V5. Let's take a simple example:
Suppose we currently have a data sequence of letters ['a', 'b', 'c'....], and we want to present it on the page using D3 and SVG.
V4#
Using selection.enter()
, selection.exit()
, and selection
(update) to specify the corresponding logic, the content is as follows:
const d3Pattern = (dataSet) => {
const text = g.selectAll('text').data(dataSet) // Data binding
text.enter() // enter() returns the part of the bound data that has not yet generated DOM elements
.append('text')
.attr('class', 'new')
.text(d => d)
.merge(text) // The code after merge will be applied to both enter and update parts, shorthand writing
.attr('x', (d, i) => 18 * i)
text.attr('class', 'update') // text itself is the update part
text.exit().remove() // exit() returns elements where data has been deleted, but DOM still exists
}
V5#
D3 V5.8.0 introduced a new API, selection.join
.
The advantage of this API is that for simpler D3 graphics that do not require special definitions for the enter/exit processes, the code can be simplified. The above code, written using V5, is as follows:
const d3Pattern = (dataSet) => {
const text = g.selectAll('text').data(dataSet) // Data binding
text.join(
enter => enter.append('text')
.attr('class', 'new')
.text(d => d),
update => update.attr('class', 'update')
)
.attr('x', (d, i) => 18 * i)
// The exit of join defaults to exit().remove(), so it can be omitted
}
It can be seen that using selection.join()
, there is no need to manually write selection.exit().remove()
, because the default exit()
function of selection.join()
has already been written for you. Under this API, D3's Update Pattern can be written as:
selection.join(
enter => // enter.. ,
update => // update.. ,
exit => // exit..
)
// Note that the enter, update, etc. functions must return, so that selection can continue to chain calls
Of course, the benefit of this API is that in general usage scenarios (where no special animations or operations are added to enter, exit, etc.), it can be completely simplified, for example:
svg.selectAll("circle")
.data(data)
.join("circle")
.attr("fill", "none")
.attr("stroke", "black");
The above writing is completely equivalent to:
svg.selectAll("circle")
.data(data)
.join(
enter => enter.append("circle"),
update => update,
exit => exit.remove()
)
.attr("fill", "none")
.attr("stroke", "black");
This is equivalent to the V4 version of:
circles = svg.selectAll('circle')
.data(data)
circles.enter()
.append('circle')
.merge('circle')
.attr('fill', 'none')
.attr('stroke', 'black')
circles.exit().remove()
Of course, V5 is fully compatible with the V4 update pattern. Whether it is the V4 or V5 new API, the essence of this Update Pattern has not changed; D3 is still about data binding, with the working modes of enter/update/exit.
Key in the Pattern#
When using d3.data
()
to bind data and DOM, the corresponding relationship may have the first element corresponding to the first DOM, the second element corresponding to the second DOM, etc.; but when the Array
changes, such as reordering or inserting, the binding relationship between the elements in the array and the DOM may undergo subtle changes.
The most intuitive example is the dynamic character change example.
As shown, the newly added characters always appear at the end. In fact, if the data consistently maintains its binding with the DOM, theoretically, randomly generating new characters should have a chance to appear in the middle.
To avoid this situation during data binding, a unique key value can be passed in:
selection.data(data, d => d.id)
Once this step is completed, simply call the function d3Pattern
regularly, passing in different data, to achieve the effect shown in the image above.
Transition#
Now that we have laid the groundwork, we finally arrive at the main character d3.transition
, but in fact, there are only a handful of related APIs. To make D3 draw with interactive and cool transition effects, the key is still to thoroughly understand the Update Pattern.
Basic Animation Usage#
The use of transition
is very similar to jQuery. When using it, you only need to call it on the selected elements and specify the properties to modify, i.e., selection.transition().attr(...)
.
For example, if there is a square on the canvas, and the element is rect
, I want to move its position from the default place to position 30 with animation. The code is:
rect.transition()
.attr('x', 30) // Set new position
The basic use of animation is that simple. Below is a brief overview of the related APIs.
Method | Description |
---|---|
selection.transition() | This schedules a transition for the selected elements |
transition.duration() | Duration specifies the animation duration in milliseconds for each element |
transition.ease() | Ease specifies the easing function, example: linear, elastic, bounce |
transition.delay() | Delay specifies the delay in animation in milliseconds for each element |
Note that D3's APIs support method chaining. Therefore, for example, in the above case, if you want to set the animation time to 1 second, you can do:
rect.transition()
.duration(1000)
.attr('x', 30) // Set new position
Similarly, ease and delay can be set to adjust the animation curve and delay, respectively.
Animation under the Update Pattern#
Returning to the initial example, here we use the V4 version of the Update Pattern as an example.
Since transition is applied to selection
, for convenience, we can first define the animation:
const t = d3.transition().duration(750)
Next, we want the newly added text to drop down from above, and when the position updates, there should be an animation effect. At this point, we need to set a downward transition during enter()
:
const d3Pattern = (dataSet) => {
const t = d3.transition().duration(750) // Define animation
const text = g.selectAll('text').data(dataSet)
text.enter()
.append('text')
.attr('class', 'new')
.text(d => d)
.attr('y', -60)
.transition(t)
.attr('y', 0)
.attr('x', (d, i) => 18 * i)
text.attr('class', 'update')
.attr('y', 0)
.transition(t)
.attr('x', (d, i) => 18 * i)
text.exit()
.transition(t)
.attr('y', 60)
.remove()
}
As can be seen, the originally rigid style has become much more dynamic. Of course, we can also continue to add red to the exiting text, along with the falling animation, to make the overall effect more dynamic. We just need to handle the exit part accordingly:
text.exit()
.transition(t)
.attr('y', 60)
.remove()
As shown, this adds a downward drop and opacity change animation effect.
Practical Application#
For example, if there is already a static bar chart, and we want to have some dynamic effects when the mouse hovers over it, as shown in the image below:
The implementation of the bar chart will not be elaborated here; instead, I will explain the core code, which follows the same logic as mentioned above:
-
Listen for mouse enter events.
-
Select the current bar and modify its properties through transition.
-
Listen for mouse leave.
-
Select the current bar, and on mouse leave, restore the properties.
The core code is as follows:
svgElement
.on('mouseenter', (actual, i) => {
d3.select(this)
.transition()
.duration(300)
.attr('opacity', 0.6)
.attr('x', (a) => xScale(a.language) - 5)
.attr('width', xScale.bandwidth() + 10)
})
.on('mouseleave', (actual, i) => {
d3.select(this)
.transition()
.duration(300)
.attr('opacity', 1)
.attr('x', (a) => xScale(a.language))
.attr('width', xScale.bandwidth())
})
The source code and tutorial for this bar chart come from D3.js Tutorial: Building Interactive Bar Charts with JavaScript.
Interpolation Animation#
For some special transitions, such as color changes or number jumps, if there are no interpolation functions, directly using transition().attr()
will not achieve the desired effect.
Therefore, D3 provides interpolation functions and interfaces for interpolation animations to implement such animations. Of course, for most scenarios, non-interpolated animations can suffice.
Special Interpolation#
For some commonly used property interpolations, D3 provides very convenient entry points, namely attrTween
(attribute interpolation) / styleTween
(style interpolation) / textTween
(text interpolation).
These interpolations are mainly used for "properties" such as colors, line thicknesses, etc., and can use attrTween()
and styleTween
. For numerical changes and continuous jumps, textTween
can be used. Their usage is similar, as shown below:
// Color interpolation, changing from red to blue
transition.attrTween('fill', function() {
return d3.interpolateRgb("red", "blue");
})
transition.styleTween('color', function() {
return d3.interpolateRgb("red", "blue");
})
The first parameter of the interpolation function is the content or property to be modified, similar to the content of attr
in transition().attr()
; the second parameter is the returned interpolation function, which can use some interpolation functions provided by D3, or a custom interpolation function.
For a simple example, if we want to achieve the following effect:
We just need to add mouse events to the element and complete it using the above interpolation functions:
svg.append('text')
.text('A')
.on('mouseenter', function() {
d3.select(this)
.transition()
.attrTween('fill', function() {
return d3.interpolateRgb("red", "blue");
})
})
.on('mouseleave', function() {...})
Next, let's discuss custom functions. For instance, if we still want to change from red to blue, we can return a self-defined function func(t)
in the interpolation function. This function will run continuously during the animation time, with t
ranging from [0, 1]. Using this idea, the above effect can be implemented with a custom function as follows:
svg.append('text')
.text('A')
.on('mouseenter', function() {
d3.select(this)
.transition()
.attrTween('fill', function() {
return function(i) {
return `rgb(${i * 255}, 0, ${255-(i * 255)})`
}
})
})
.on('mouseleave', function() {
... // Similar to above
})
Both solutions can achieve the animated effect.
It can be seen that for interpolation animations, the core lies in generating the interpolation content. D3 provides various interpolations, and the related list is as follows. For example, when using numerical jump animations, d3.interpolateRound(start, end)
can be used to generate integer interpolations; d3.interpolateRgb(color, color2)
can be used to generate color interpolations, etc. Specific usage of interpolation functions can be found in the relevant API.
-
d3.interpolateNumber
-
d3.interpolateRound
-
d3.interpolateString
-
d3.interpolateRgb
-
d3.interpolateHsl
-
d3.interpolateLab
-
d3.interpolateHcl
-
d3.interpolateArray
-
d3.interpolateObject
-
d3.interpolateTransform
-
d3.interpolateZoom
General Interpolation#
Of course, in addition to the APIs mentioned earlier, there is a more general interpolation function API, d3.tween()
.
Similar to attrTween()
and others, its second parameter also takes an interpolation function; the difference is that the first parameter can accept a more general content to be changed. For example, for the fill
property mentioned above, using the general interpolation function would be written as:
selection.transition()
.tween('attr.fill', function() {
return function(i) {
return `rgb(${i * 255}, 0, ${255-(i * 255)})`
}
})
Thus, we find that the general API is very similar to the usage of the three special APIs mentioned earlier; the only difference is that the general API's first parameter can accept a broader range of changing properties.
I won't provide more examples here; you can refer to some examples of interpolation functions here.