Vincent Ko

VK's Blog

D3.js Animation

D3.js provides various tools to support interactive data visualization, among which d3.transition makes it possible to add animations to images in a simple and efficient way.

From an API perspective, d3.transition is very straightforward, similar to jQuery. However, to design ideal animation effects, one must mention a core concept in D3's drawing graphics: 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 it comes to dynamic data, this Update Pattern not only helps write maintainable code but also better leverages D3's powerful capabilities.

General Update Pattern#

image-20200308211328582

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 page does not yet have corresponding DOM elements.

  • Update: Data elements are bound to DOM elements.

  • Exit: Data elements have been deleted, but DOM elements still exist, meaning the DOM has lost its bound elements.

Regarding this point, no detailed elaboration will be provided 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 data ['a', 'b', 'c'....] and we want to present it on the page using SVG through D3.

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 some simpler D3 graphics that do not require special definitions for the enter/exit process, 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 in 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 V4's update pattern. Whether it's the V4 or V5 new API, the essence of this Update Pattern has not changed; D3 is still about data binding and the working modes of enter/update/exit.

Keys in the Pattern#

When using d3.data () to bind data and DOM, the corresponding relationship might have the first element corresponding to the first DOM, the second element corresponding to the second DOM, and so on. However, when the Array changes, such as through reordering or inserting, the binding relationship between the elements in the array and the DOM may undergo subtle changes.

The most intuitive example is dynamically changing characters.

Kapture 2020-03-08 at 21.56.19

As shown, the newly added characters always appear at the end. In reality, if the data consistently maintains its binding with the DOM, theoretically, randomly generated 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 the above steps are completed, simply call the function d3Pattern at regular intervals, passing in different data to achieve the effect shown in the image.

Complete code

Transition#

Now that we've laid the groundwork, we finally arrive at the main character d3.transition. However, in reality, the related APIs are few. 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(...).

Kapture 2020-03-08 at 22.17.46

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 usage of animations is that simple. Below is a brief overview of 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 chaining, so 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.

Animations under the Update Pattern#

Returning to the initial example, here is an example using the V4 version of the Update Pattern.

Since transition is applied to selection, to facilitate use, we can define the animation first:

const t = d3.transition().duration(750)

Next, we want the newly added text to drop down from above, and when the position updates, we want an animation effect. At this point, we need to set a transition for the position 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()
}

Kapture 2020-03-08 at 22.40.14

As can be seen, the originally stiff style has become much more dynamic. Of course, we can also continue to add red to the text that exits, 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()

Kapture 2020-03-08 at 22.46.24

As shown, this adds a falling and opacity-changing animation effect.

Complete code

Practical Applications#

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:

Kapture 2020-03-08 at 23.03.53

The implementation of the bar chart will not be elaborated here; instead, I will explain the core code, which follows the same logic mentioned above:

  1. Listen for mouse enter events.

  2. Select the current bar and modify properties through transition.

  3. Listen for mouse leave.

  4. Select the current bar, and upon mouse leave, restore 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 Animations#

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 will suffice.

Special Interpolations#

For some commonly used property interpolations, D3 provides very convenient entry points, namely attrTween (attribute interpolation), styleTween (style interpolation), and textTween (text interpolation).

These interpolations are mainly used for interpolating properties like color, line thickness, 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 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 you can define your own interpolation function.

For a simple example, if we want to achieve the following effect:

Kapture 2020-03-13 at 20.30.23

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 talk about custom functions. For example, if we still want to change from red to blue, we can return our 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.

As can be seen, 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, you can use d3.interpolateRound(start, end) to generate integer interpolations; d3.interpolateRgb(color, color2) to generate color interpolations, etc. For specific usage of interpolation functions, please refer to 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 is also an interpolation function. The difference is that the first parameter can accept more general content to be changed. For example, using the general interpolation function for the fill property would look like this:

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 properties to be changed.

I won't provide more examples here. You can refer to some examples of interpolation functions here.

References#

  1. D3.js Tutorial: Building Interactive Bar Charts with JavaScript

  2. How to work with D3.js’s general update pattern

  3. Interaction and Animation: D3 Transitions, Behaviors, and Brushing

  4. d3-selection-join

  5. sortable-bar-chart

  6. Using Basic and Tween Transitions in d3.js

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.