Chapter 4. Events, Interactivity, and Animation

When rendered by a browser, SVG elements can receive user events and can be manipulated as a whole (for example, to change their position or appearance). This means that they behave essentially like widgets in a GUI toolkit. That’s an exciting proposition: to regard SVG as a widget set for graphics. This chapter discusses the options available to create those essential features of a user interface: interactivity and animation.

Events

An important aspect of the DOM is its event model: essentially any DOM element can receive events and invoke an appropriate handler. The number of different event types is very large; most important for our purposes are user-generated events (mouse clicks or movements, as well as keystrokes; see Table 4-1).1

Table 4-1. Some important user-generated event types
Function Description

click

Any mouse button is pressed and released on an element.

mousemove

The mouse is moved while over an element.

mousedown, mouseup

A mouse button is pressed or released over an element.

mouseenter, mouseleave

The mouse pointer is moved onto or off of an element.

mouseover, mouseout

The mouse pointer is moved onto or off of an element, or any of its children.

keydown, keyup

Any key is pressed or released.

D3 treats event handling as part of the Selection abstraction (see Table 4-2). If sel is a Selection instance, then you use the following member function to register a callback as event handler for the specified event type:

sel.on( type, callback )

The type argument must be a string indicating the event type (such as "click"). Any DOM event type is permitted. If a handler was already registered for the event type via on(), it is removed before the new handler is registered. To explicitly remove the handler for a given event type, provide null as the second argument. To register multiple handlers for the same event type, the type name may be followed by a period and an arbitrary tag (so that the handler for "click.foo" does not overwrite the one for "click.bar").

The callback is a function invoked when an event of the specified type is received by any element of the selection. The callback will be invoked in the same way as any other accessor is invoked in the context of a selection, being passed the data point d bound to the current element, the element’s index i in the current selection, and the nodes in the current selection, while this contains the current element itself.2 The actual event instance is not passed to the callback as an argument, but it is available in the variable:

d3.event

When an event occurs, this variable contains the raw DOM event instance (not a D3 wrapper!). The information provided by the event object itself depends on the event type. For mouse events, naturally the location of the mouse pointer when the event occurred is of particular interest. The event object contains the mouse coordinates with respect to three different coordinate systems,3 but none directly provides the information that would be most useful, namely the position with respect to the containing parent element! Thankfully, they can be obtained using:

((("d3.mouse() function")))d3.mouse( node )

This function returns the mouse coordinates as a two-element array [x, y]. The argument should be the enclosing container element (as a DOM Node, not as Selection). When working with SVG, you can supply any element (as a Node), and the function will calculate the coordinates relative to the nearest ancestor SVG element.

Table 4-2. Some important methods, variables, and functions related to event handling (sel is a Selection object)
Function Description

sel.on( types, callback )

Adds or removes a callback for each element in the selection. The types argument must be a string consisting of one or more event type names, separated by whitespace. An event type may be followed by a period and an arbitrary tag to allow multiple handlers to be registered for a single event type.

  • If a callback is specified, it is registered as the event handler; any existing event handler is removed first.

  • If the callback argument is null, any existing handler is removed.

  • If the callback argument is missing, the currently assigned handler is returned.

d3.event

Contains the current event, if any, as a DOM Event object.

d3.mouse( parent )

Returns a two-element array containing the mouse coordinates relative to the specified parent.

sel.dispatch( type )

Dispatches a custom event of the specified type to all elements in the current selection.

Exploring Graphs with the Mouse

For someone working analytically with data, these features offer some exciting opportunities because they make it easy to explore graphs interactively: point the mouse at a spot on the graph and get additional information about the data point located there. Here is a simple example. If you call the function in Example 4-1, while supplying a CSS selector string (see “CSS Selectors”) that identifies an <svg> element, the current mouse pointer location (in pixel coordinates) will be shown in the graph itself. Moreover, the location of the textual display is not fixed but will move together with the mouse pointer.

Example 4-1. Given a CSS selector string, this function will continuously display the mouse position in pixel coordinates whenever the user moves the mouse.
function coordsPixels( selector ) {
    var txt = d3.select( selector ).append( "text" );             1
    var svg = d3.select( selector ).attr( "cursor", "crosshair" ) 2
        .on( "mousemove", function() {
            var pt = d3.mouse( svg.node() );                      3
            txt.attr( "x", 18+pt[0] ).attr( "y", 6+pt[1] )        4
                .text( "" + pt[0] + "," + pt[1] );
        } );
}
1

Create the <text> element to display the coordinates. It is important to do this outside of the event callback; otherwise, a new <text> element will be created every time the user moves the mouse!

2

Change the shape of the mouse cursor while over the <svg> element. This is not required, of course—but it is a fitting effect (and also demonstrates how the mouse cursor can be changed through attributes; see Appendix B).

3

Obtain the mouse coordinates, relative to the upper-left corner of the <svg> element, using the d3.mouse() convenience function.

4

Update the text element created earlier. In this example, both the displayed text content of the element and its position are updated: slightly to the right of the mouse position.

Displaying the mouse coordinates is, of course, neither new nor particularly exciting. But what is exciting is to see just how easy it is to implement such behavior in D3!

Case Study: Simultaneous Highlighting

The next example is more interesting. It addresses a common problem when working with multivariate data sets: how to link two different views or projections of the data visually. One way is to select a region of data points in one view with the mouse and simultaneously highlight the corresponding points in all other views. In Figure 4-1, points are highlighted in both panels according to their distance (in pixel coordinates) from the mouse pointer in the lefthand panel. Because this example is more involved, we will first discuss a simplified version (see Example 4-2).

dfti 0401
Figure 4-1. Data points belonging to the same record are simultaneously highlighted in both panels, based on the distance of the points in the lefthand panel to the mouse pointer.
Example 4-2. Commands for Figure 4-1
function makeBrush() {
    d3.csv( "dense.csv" ).then( function( data ) {                1
        var svg1 = d3.select( "#brush1" );                        2
        var svg2 = d3.select( "#brush2" );

        var sc1=d3.scaleLinear().domain([0,10,50])                3
            .range(["lime","yellow","red"]);
        var sc2=d3.scaleLinear().domain([0,10,50])
            .range(["lime","yellow","blue"]);

        var cs1 = drawCircles(svg1,data,d=>d["A"],d=>d["B"],sc1); 4
        var cs2 = drawCircles(svg2,data,d=>d["A"],d=>d["C"],sc2);

        svg1.call( installHandlers, data, cs1, cs2, sc1, sc2 );   5
    } );
}

function drawCircles( svg, data, accX, accY, sc ) {
    var color = sc(Infinity);                                     6
    return svg.selectAll( "circle" ).data( data ).enter()
        .append( "circle" )
        .attr( "r", 5 ).attr( "cx", accX ).attr( "cy", accY )
        .attr( "fill", color ).attr( "fill-opacity", 0.4 );
}

function installHandlers( svg, data, cs1, cs2, sc1, sc2 ) {
    svg.attr( "cursor", "crosshair" )
        .on( "mousemove", function() {
            var pt = d3.mouse( svg.node() );

            cs1.attr( "fill", function( d, i ) {                  7
                var dx = pt[0] - d3.select( this ).attr( "cx" );
                var dy = pt[1] - d3.select( this ).attr( "cy" );
                var r = Math.hypot( dx, dy );

                data[i]["r"] = r;                                 8
                return sc1(r); } );                               9

            cs2.attr( "fill", (d,i) => sc2( data[i]["r"] ) ); } ) 10

        .on( "mouseleave", function() {
            cs1.attr( "fill", sc1(Infinity) );                    11
            cs2.attr( "fill", sc2(Infinity) ); } );
}
1

Load the data set and specify the callback to invoke when the data is available (see Chapter 6 for more information about fetching data). The file contains three columns, labeled A, B, and C.

2

Select the two panels of the graph.

3

D3 can smoothly interpolate between colors. Here we create two color gradients (one for each panel). (See Chapter 7 to learn more about interpolation and scale objects.)

4

Create the circles representing data points. The newly created circles are returned as Selection objects. Following a general D3 convention, columns are specified in the function call by providing accessor functions.

5

Call the function installHandlers() to register the event handlers. This line of code uses the call() facility to invoke the installHandlers() function, while supplying the svg1 selection and the remaining parameters as arguments. (We encountered this already in Example 2-6; also see the discussion regarding components in Chapter 5.)

6

Initially, the circles are drawn with the “maximum” color. To find this color, evaluate the color scale at positive infinity.

7

For each point in the panel on the left, calculate its distance to the mouse pointer…

8

… and store it, as an additional column, in the data set. (This will be our mechanism of communication between the two panels of the figure.)

9

Return the appropriate color from the color gradient.

10

Use the additional column in the data set to color the points in the panel on the right.

11

Restore the points to their original colors when the mouse leaves the lefthand panel.

This version of the program works well and solves the original problem. The improved version of the installHandlers() function shown in Example 4-3 allows us to discuss some additional techniques when writing this kind of user interface code.

Example 4-3. An improved version of the installHandlers() function in Example 4-2
function installHandlers2( svg, data, cs1, cs2, sc1, sc2 ) {
    var cursor = svg.append( "circle" ).attr( "r", 50 )           1
        .attr( "fill", "none" ).attr( "stroke", "black" )
        .attr( "stroke-width", 10 ).attr( "stroke-opacity", 0.1 )
        .attr( "visibility", "hidden" );                          2

    var hotzone = svg.append( "rect" ).attr( "cursor", "none" )   3
        .attr( "x", 50 ).attr( "y", 50 )
        .attr( "width", 200 ).attr( "height", 200 )
        .attr( "visibility", "hidden" )                           4
        .attr( "pointer-events", "all" )

        .on( "mouseenter", function() {                           5
            cursor.attr( "visibility", "visible" ); } )

        .on( "mousemove", function() {                            6
            var pt = d3.mouse( svg.node() );
            cursor.attr( "cx", pt[0] ).attr( "cy", pt[1] );

            cs1.attr( "fill", function( d, i ) {
                var dx = pt[0] - d3.select( this ).attr( "cx" );
                var dy = pt[1] - d3.select( this ).attr( "cy" );
                var r = Math.hypot( dx, dy );

                data[i]["r"] = r;
                return sc1(r); } );

            cs2.attr( "fill", (d,i) => sc2( data[i]["r"] ) ); } )

        .on( "mouseleave", function() {
            cursor.attr( "visibility", "hidden" );
            cs1.attr( "fill", sc1(Infinity) );
            cs2.attr( "fill", sc2(Infinity) ); } )
}
1

In this version, the actual mouse pointer itself is hidden and replaced with a large, partially opaque circle. Points within the circle will be highlighted.

2

Initially, the circle is hidden. It will only be shown once the mouse pointer enters the “hot zone.”

3

The “hot zone” is defined as a rectangle inside the lefthand panel. The event handlers are registered on this rectangle, meaning that they will only be invoked when the mouse pointer is inside of it.

4

The rectangle is hidden from view. By default, DOM elements that have their visibility attribute set to hidden do not receive mouse pointer events. To overcome this, the pointer-events attribute must be set explicitly. (Another way to make an element invisible is to set its fill-opacity to 0. In this case, it will not be necessary to modify the pointer-events attribute.)

5

When the mouse enters the “hot zone,” the opaque circle that acts as the pointer is displayed.

6

The mousemove and mouseleave event handlers are equivalent to the ones in Example 4-2, except for the additional commands to update the circle acting as a cursor.

The use of an active “hot zone” in this example is of course optional, but it demonstrates an interesting technique. At the same time, the discussion of the pointer-events attribute suggests that this kind of user interface programming may involve unexpected challenges. We will come back to this point after the next example.

The D3 Drag-and-Drop Behavior Component

Several common user interface patterns consist of a combination of events and responses: in the drag-and-drop pattern, for instance, the user first selects an item, then moves it, and finally releases it again. D3 includes a number of predefined behavior components that simplify the development of such user interface code by bundling and organizing the required actions. In addition, these components also unify some details of the user interface.

Consider a situation like the one in Figure 4-2, showing the following SVG snippet:

<svg id="dragdrop" width="600" height="200">
  <circle cx="100" cy="100" r="20" fill="red" />
  <circle cx="300" cy="100" r="20" fill="green" />
  <circle cx="500" cy="100" r="20" fill="blue" />
</svg>
dfti 0402
Figure 4-2. The initial configuration for the drag-and-drop behavior

Now let’s enable the user to change the position of the circles with the mouse. It is not difficult to add the familiar drag-and-drop pattern by registering callbacks for mousedown, mousemove, and mouseup events, but Example 4-4 uses the D3 drag behavior component instead. As explained in Example 2-6, a component is a function object that takes a Selection instance as argument and adds DOM elements to that Selection (also see Chapter 5). A behavior component is a component that installs required event callbacks in the DOM tree. At the same time, it is also an object that has member functions itself. The listing uses the drag component’s on( type, callback ) member function to specify the callbacks for the different event types.

Example 4-4. Using the drag-and-drop behavior
function makeDragDrop() {
    var widget = undefined, color = undefined;

    var drag = d3.drag()                                          1
        .on( "start", function() {                                2
            color = d3.select( this ).attr( "fill" );
            widget = d3.select( this ).attr( "fill", "lime" );
        } )
        .on( "drag", function() {                                 3
            var pt = d3.mouse( d3.select( this ).node() );
            widget.attr( "cx", pt[0] ).attr( "cy", pt[1] );
        } )
        .on( "end", function() {                                  4
            widget.attr( "fill", color );
            widget = undefined;
        } );

    drag( d3.select( "#dragdrop" ).selectAll( "circle" ) );       5
}
1

Create a drag function object using the factory function d3.drag(), then invoke the on() member function on the returned function object to register the required callbacks.

2

The start handler stores the current color of the selected circle; then changes the selected circle’s color and assigns the selected circle itself (as a Selection) to widget.

3

The drag handler retrieves the current mouse coordinates and moves the selected circle to this location.

4

The end handler restores the circle’s color and clears the active widget.

5

Finally, invoke the drag component operation while supplying a selection containing the circles to install the configured event handlers on the selection.

A more idiomatic way to express this would use the call() function rather than invoking the component operation explicitly:

d3.select( "#dragdrop" ).selectAll( "circle" )
    .call( d3.drag()
           .on( "start", function() { ... } )
           .on( "drag", function() { ... } )
           .on( "end", function() { ... } ) );

The event names in Example 4-4 may come as a surprise: these are not standard DOM events, but D3 pseudoevents. The D3 drag behavior combines both mouse and touch-screen event handling. Internally, the start pseudoevent corresponds to either a mousedown or a touchstart event, and similar for drag and end. Furthermore, the drag behavior prevents the browser’s default action for certain event types.4 D3 includes additional behaviors to assist with zooming and when selecting parts of a graph with a mouse.

Notes on User Interface Programming

I hope the examples so far have convinced you that creating interactive graphs using D3 need not be difficult—in fact, I believe D3 makes them feasible even for ad hoc, one-off tasks and explorations. At the same time, as the discussion after the previous two examples shows, graphical user interface programming is still a relatively complex problem. Many components, each with its own rules, participate and can interact in unexpected ways. Browsers may differ in their implementation. Here are some reminders and potential surprises (also see Appendix C for background information on DOM event handling):

  • Repeated calls to on() for the same event type on the same Selection instance clobber each other. Add a unique tag to the event type (separated by a period) to register multiple event handlers.

  • If you want to access this in a callback or accessor function, you must use the function keyword, you cannot use an arrow function. This is a limitation of the JavaScript language (see Appendix C). Examples can be found in the installHandlers() function in Examples 4-2 and 4-3, and several times in Example 4-4.

  • Browser default behavior may interfere with your code; you may need to prevent it explicitly.

  • Generally, only visible, painted elements can receive mouse pointer events. Elements with their visibility attribute set to hidden, or with both fill and stroke set to none, do not receive pointer events by default. Use the pointer-events attribute for fine-grained control over the conditions under which elements will receive events. (See MDN Pointer-Events.)

  • In a similar spirit, a <g> element has no visual representation, and hence does not generate pointer events. Nevertheless, it may be appropriate to register an event handler on a <g> element because events generated by any of its (visible) children will be delegated to it. (Use an invisible rectangle or other shape to define active “hot zones,” as in Example 4-3.)

Smooth Transitions

An obvious way to respond to events is to apply some change to the figure’s appearance or configuration (for example, to show a before-and-after effect). In this case, it is often useful to let the change take place gradually, rather than instantaneously, to draw attention to the change that is taking place and to allow users to discern additional detail. For example, users may now be able to recognize which data points are most affected by the change, and how (see Figure 3-3 and Example 3-1 for an example).

Conveniently, the D3 Transition facility does all the work for you. It replicates most of the Selection API, and you can change the appearance of selected elements using attr() or style() as before (see Chapter 3). But now the new settings do not take effect immediately; instead, they are applied gradually over a configurable time span (see Example 2-8 for an early example).

Under the covers, D3 creates and schedules the required intermediate configurations to give the appearance that the graph is changing smoothly over the desired duration. To do so, D3 invokes an interpolator that creates the intermediate configurations between the starting and end points. The D3 interpolation facility is fairly smart and able to interpolate automatically between most types (such as numbers, dates, colors, strings with embedded numbers, and more—see Chapter 7 for a detailed description).

Creating and Configuring Transitions

The workflow to create a transition is simple (also see Table 4-3):

  1. Before creating a transition, make sure any data has been bound and all elements that are supposed to be part of the transition have been created (using append() or insert())—even if they are initially set to be invisible! (The Transition API allows you to change and remove elements, but it does not provide for the creation of elements as part of the transition.)

  2. Now select the elements you wish to change using the familiar Selection API.

  3. Invoke transition() on this selection to create a transition. Optionally, call duration(), delay(), or ease() for more control over its behavior.

  4. Set the desired end state using attr() or style() as usual. D3 will create the intermediate configurations between the current values and the indicated end states, and apply them over the duration of the transition.

Often, these commands will be part of an event handler, so that the transition starts when an appropriate event occurs.

Table 4-3. Functions to create and terminate a transition (sel is a Selection object; trans is aTransition object)
Function Description

sel.transition( tag )

Returns a new transition on the receiving selection. The optional argument may be a string (to identify and distinguish this transition on the selection) or a Transition instance (to synchronize transitions).

sel.interrupt( tag )

Stops the active transition and cancels any pending transitions on the selected elements for the given identifier. (Interrupts are not forwarded to children of the selected elements.)

trans.transition()

Returns a new transition on the same selected elements as the receiving transition, scheduled to start when the current transition ends. The new transition inherits the current transition’s configuration.

trans.selection()

Returns the selection for a transition.

In addition to the desired end point, a Transition also allows you to configure several aspects of its behavior (see Table 4-4). All of these have reasonable defaults, making explicit configuration optional:

  • A delay that must pass before the change begins to take effect.

  • A duration over which the setting will change gradually.

  • An easing that controls how the rate of change will differ over the transition duration (to “ease into” and “out of” the animation). By default, the easing follows a piecewise cubic polynomial with “slow-in, slow-out” behavior.

  • An interpolator to calculate the intermediate values (this is rarely necessary, because the default interpolators handle most common configurations automatically).

  • An event handler to execute custom code when the transition starts, ends, or is interrupted.

Table 4-4. Functions to configure a transition or to retrieve the current setting if called without argument (trans is a Transition object)
Function Description

trans.delay( value )

Sets the delay (in milliseconds) before the transition begins for each element in the selection; the default is 0. The delay can be given as a constant or as a function. If it is a function, the function will be invoked once for each element, before the transition begins, and should return the desired delay. The function will be passed the data bound to the element d and its index in the selection i.

trans.duration( value )

Sets the duration (in milliseconds) of the transition for each element in the selection; the default is 250 milliseconds. The duration can be given as a constant or as a function. If it is a function, the function will be invoked once for each element, before the transition begins, and should return the desired duration. The function will be passed the data bound to the element d and its index in the selection i.

trans.ease( fct )

Sets the easing function for all selected elements. The easing must be a function, taking a single parameter between 0 and 1, and returning a single value, also between 0 and 1. The default easing is d3.easeCubic (a piecewise defined cubic polynomial with “slow-in, slow-out” behavior).

trans.on( type, handler )

Adds an event handler on the transition. The type must be start, end, or interrupt. The event handler will be invoked at the appropriate point in the transition’s lifecycle. This function behaves similarly to the on() function on a Selection object see Table 4-2).

Using Transitions

The Transition API replicates large parts of the Selection API. In particular, all functions from Table 3-2 (that is, select(), selectAll(), and filter()) are available. From Table 3-4, attr(), style(), text(), and each() carry over, as well as all functions from Table 3-5 except append(), insert(), and sort(). (As was pointed out earlier, all elements participating in a transition must exist before the transition is created. For the same reason, none of the functions for binding data from Table 3-3 exist for transitions.)

Basic transitions are straightforward to use, as we have already seen in an example in an earlier chapter (Example 3-1). The application in Example 4-5 is still simple, but the effect is more sophisticated: a bar chart is updated with new data, but the effect is staggered (using delay()) so that the bars don’t all change at the same time.

dfti 0403
Figure 4-3. When this bar chart is updated with a new data set, the updates are applied consecutively, left to right.
Example 4-5. Using transitions (see Figure 4-3)
function makeStagger() {
    var ds1 = [ 2, 1, 3, 5, 7, 8, 9, 9, 9, 8, 7, 5, 3, 1, 2 ];    1
    var ds2 = [ 8, 9, 8, 7, 5, 3, 2, 1, 2, 3, 5, 7, 8, 9, 8 ];
    var n = ds1.length, mx = d3.max( d3.merge( [ds1, ds2] ) );    2

    var svg = d3.select( "#stagger" );

    var scX = d3.scaleLinear().domain( [0,n] ).range( [50,540] ); 3
    var scY = d3.scaleLinear().domain( [0,mx] ).range( [250,50] );

    svg.selectAll( "line" ).data( ds1 ).enter().append( "line" )  4
        .attr( "stroke", "red" ).attr( "stroke-width", 20 )
        .attr( "x1", (d,i)=>scX(i) ).attr( "y1", scY(0) )
        .attr( "x2", (d,i)=>scX(i) ).attr( "y2", d=>scY(d) );

    svg.on( "click", function() {                                 5
        [ ds1, ds2 ] = [ ds2, ds1 ];                              6

        svg.selectAll( "line" ).data( ds1 )                       7
            .transition().duration( 1000 ).delay( (d,i)=>200*i )  8
            .attr( "y2", d=>scY(d) );                             9
    } );
}
1

Define two data sets. To keep things simple, only the y values are included; we will be using the array index of each item for its horizontal position.

2

Find the number of data points, and the overall maximal value across both data sets.

3

Two scale objects that map the values in the data set to vertical, and their index positions in the array to horizontal pixel coordinates.

4

Create the bar chart. Each “bar” is realized as a thick line (rather than a <rect> element).

5

Register an event handler for "click" events.

6

Interchange the data sets.

7

Bind the (updated) data set ds1 to the selection…

8

… and create a transition instance. Each bar will take one second to attain its new size, but will start only after a delay. The delay is dependent on the horizontal position of each bar, growing left to right. This has the effect that the “update” seems to sweep across the chart.

9

Finally, set the new vertical length of each line. This is the end point for the transition.

Hints and Techniques

Not all transitions are as straightforward as the ones we have seen so far. Here are some additional hints and techniques.

Strings

The D3 default interpolators will interpolate numbers that are embedded in strings, but leave the rest of the string alone because there is no generally useful way to interpolate between strings. The best way to achieve a smooth transition between strings is to cross-fade between two strings in the same location. Assume that two suitable <text> elements exist:

<text id="t1" x="100" y="100" fill-opacity="1">Hello</text>
<text id="t2" x="100" y="100" fill-opacity="0">World</text>

Then you can cross-fade between them by changing their opacity (possibly changing the duration of the transition):

d3.select("#t1").transition().attr( "fill-opacity", 0 );
d3.select("#t2").transition().attr( "fill-opacity", 1 );

An alternative that may make sense in certain cases is to write a custom interpolator to generate intermediate string values.

Chained transitions

Transitions can be chained so that one transition begins when the first one ends. The subsequent transitions inherit the earlier transition’s duration and delay (unless they are overridden explicitly). The following code will turn the selected elements first to red, then to blue:

d3.selectAll( "circle" )
    .transition().duration( 2000 ).attr( "fill", "red" )
    .transition().attr( "fill", "blue" );

Explicit starting configuration

Unless you plan to use a custom interpolator (see next), it is important that the starting configuration is set explicitly. For example, don’t rely on the default value (black) for the fill attribute: unless the fill attribute is set explicitly, the default interpolator will not know what to do.

Custom interpolators

Using the methods in Table 4-5, it is possible to specify a custom interpolator to be used during the transition. The methods to set a custom interpolator take a factory function as argument. When the transition starts, the factory function is invoked for each element in the selection, being passed the data d bound to the element and the element’s index i, with this being set to the current DOM Node. The factory must return an interpolator function. The interpolator function must accept a single numeric argument between 0 and 1 and must return an appropriate intermediate value between the starting and the end configuration. The interpolator will be called after any easing has been applied. The following code uses a simple custom color interpolator without easing (see Chapter 8 to learn about more flexible ways to operate on colors in D3):

d3.select( "#custom" ).selectAll( "circle" )
    .attr( "fill", "white" )
    .transition().duration( 2000 ).ease( t=>t )
    .attrTween( "fill", function() {
        return t => "hsl(" + 360*t + ", 100%, 50%)"
    } );

The next example is more interesting. It creates a rectangle centered at the position (100, 100) in the graph and then rotates the rectangle smoothly around its center. (D3 default interpolators understand some SVG transformations, but this example demonstrates how to write your own interpolator in case you need to.)

d3.select( "#custom" ).append( "rect" )
    .attr( "x", 80 ).attr( "y", 80 )
    .attr( "width", 40 ).attr( "height", 40 )
    .transition().duration( 2000 ).ease( t=>t )
    .attrTween( "transform", function() {
        return t => "rotate(" + 360*t + ",100,100)"
    } );

Transition events

Transitions emit custom events when they start, end, and are interrupted. Using the on() method, you can register an event handler on a transition, which will be called when the appropriate lifecycle event is emitted. (See the D3 Reference Documentation for details.)

Easings

Using the ease() method, you can specify an easing. The purpose of an easing is to “stretch” or “compress” the time seen by the interpolator, allowing the animation to “ease into and out of” the movement. This often enhances the visual affect of an animation dramatically. As a matter of fact, “slow-in, slow-out” has been recognized by animators at Disney as one of the “Twelve Principles of Animation” (see “Principles of Animation”). But at other times, when they don’t match well with the way the user expects an object to behave, easings can be downright confusing. The balance is definitely subtle.

An easing takes a parameter in the interval [0, 1] and maps it to the same interval, starting for t = 0 at 0 and ending for t = 1 at 1. The mapping is typically nonlinear (otherwise, the easing is just the identity). The default easing is d3.easeCubic, which implements a version of “slow-in, slow-out” behavior.

Technically, an easing is simply a mapping that is applied to the time parameter t before it is passed to the interpolator. This makes the distinction between the easing and the interpolator somewhat arbitrary. What if a custom interpolator itself mangles the time parameter in some nonlinear way? From a practical point of view, it seems best to treat easings as a convenience feature that adds “slow-in, slow-out” behavior to standard interpolators. (D3 includes a confusingly large range of different easings, some of which considerably blur the distinction between an easing and what should be considered a custom interpolator.)

Don’t overuse transitions

Transitions can be overused. A general problem with transitions is that they usually can’t be interrupted by the user: the resulting forced wait can quickly lead to frustration. When transitions are employed to allow the user to track the effects of a change, they aid understanding (see Figure 3-3 for a simple example). But when they are used just “for effect,” they easily become tiring once the initial cuteness wears off. (Figure 4-3 can serve as a cautionary example in this spirit!)

Table 4-5. Methods to specify custom interpolators (trans is a Transition object)
Function Description

trans.attrTween( name, factory )

Sets a custom interpolator for the named attribute. The second argument must be a factory method that returns an interpolator.

trans.styleTween( name, factory )

Sets a custom interpolator for the named style. The second argument must be a factory method that returns an interpolator.

trans.tween( tag, factory )

Sets a custom interpolator to be invoked during transitions. The first argument is an arbitrary tag to identify this interpolator, the second argument must be a factory method that returns an interpolator. The effect of the interpolator is not restricted; it will be invoked purely for its side effects.

Animation with Timer Events

Transitions are a convenience technique to transform a configuration smoothly into another, but they are not intended as framework for general animations. To create those, it is generally necessary to work on a lower level. D3 includes a special timer that will invoke a given callback once per animation frame, that is, every time the browser is about to repaint the screen. The time interval is not configurable because it is determined by the browser’s refresh rate (about 60 times per second or every 17 milliseconds, for most browsers). It is also not exact; the callback will be passed a high-precision timestamp that can be used to determine how much time has passed since the last invocation (see Table 4-6).

Table 4-6. Functions and methods for creating and using timers (t is a Timer object)
Function Description

d3.timer( callback, after, start )

Returns a new timer instance. The timer will invoke the callback perpetually once per animation frame. When invoked, the callback will be passed the apparent elapsed time since the timer started running. (Apparent elapsed time does not progress while the window or tab is in the background.) The numeric start argument may contain a timestamp, as returned by d3.now(), at which the timer is scheduled to begin (it defaults to now). The numeric after argument may contain a delay, in milliseconds, which will be added to the start time (it defaults to 0).

d3.timeout( callback, after, start )

Like d3.timer(), except that the callback will be invoked exactly once.

d3.interval( callback, interval, start )

Similar to d3.timer(), except that the callback will only be invoked every interval milliseconds.

t.stop()

Stops this timer. Has no effect if the timer is already stopped.

d3.now()

Returns the current time, in milliseconds.

Example: Real-Time Animations

Example 4-6 creates a smooth animation by updating a graph for every browser repaint. The graph (see the left side of Figure 4-4) draws a line (a Lissajous curve5) that slowly fades as time goes on. In contrast to most other examples, this code does not use binding—mostly because there is no data set to bind! Instead, at each time step, the next position of the curve is calculated and a new <line> element is added to the graph from the previous position to the new one. The opacity of all elements is reduced by a constant factor, and elements whose opacity has fallen so low as to be essentially invisible are removed from the graph. The current value of the opacity is stored in each DOM Node itself as a new, “bogus” property. This is optional; you could instead store the value in a separate data structure keyed by each node (for example, using d3.local(), which is intended for this purpose), or query the current value using attr(), update, and reset it.

Example 4-6. Real-time animation (see the left side of Figure 4-4)
function makeLissajous() {
    var svg = d3.select( "#lissajous" );

    var a = 3.2, b = 5.9;                 // Lissajous frequencies
    var phi, omega = 2*Math.PI/10000;     // 10 seconds per period

    var crrX = 150+100, crrY = 150+0;
    var prvX = crrX, prvY = crrY;

    var timer = d3.timer( function(t) {
        phi = omega*t;

        crrX = 150+100*Math.cos(a*phi);
        crrY = 150+100*Math.sin(b*phi);

        svg.selectAll( "line" )
            .each( function() { this.bogus_opacity *= .99 } )
            .attr( "stroke-opacity",
                   function() { return this.bogus_opacity } )
            .filter( function() { return this.bogus_opacity<0.05 } )
            .remove();

        svg.append( "line" )
            .each( function() { this.bogus_opacity = 1.0 } )
            .attr( "x1", prvX ).attr( "y1", prvY )
            .attr( "x2", crrX ).attr( "y2", crrY )
            .attr( "stroke", "green" ).attr( "stroke-width", 2 );

        prvX = crrX;
        prvY = crrY;

        if( t > 120e3 ) { timer.stop(); } // after 120 seconds
    } );
}
dfti 0404
Figure 4-4. Animations: a Lissajous figure (left, see Example 4-6), and a voter model (right, see Example 4-7)

Example: Smoothing Periodic Updates with Transitions

In the previous example, each new data point to be displayed was calculated in real time. That’s not always possible. Imagine that you need to access a remote server for data. You might want to poll it periodically, but certainly not for each repaint. In any case, a remote fetch is always an asynchronous call and needs to be handled accordingly.

In such a situation, transitions can help to create a better user experience by smoothing out the time periods between updates from the data source. In Example 4-7, the remote server has been replaced by a local function to keep the example simple, but most of the concepts carry over. The example implements a simple voter model:6 at each time step, each graph element randomly selects one of its eight neighbors and adopts its color. The update function is called only every few seconds; D3 transitions are used to update the graph smoothly in the meantime (see the right side of Figure 4-4).

Example 4-7. Using transitions to smooth out periodic updates (see the right side of Figure 4-4)
function makeVoters() {
    var n = 50, w=300/n, dt = 3000, svg = d3.select( "#voters" );

    var data = d3.range(n*n)                                      1
        .map( d => { return { x: d%n, y: d/n|0,
                              val: Math.random() } } );

    var sc = d3.scaleQuantize()                                   2
        .range( [ "white", "red", "black" ] );

    svg.selectAll( "rect" ).data( data ).enter().append( "rect" ) 3
        .attr( "x", d=>w*d.x ).attr( "y", d=>w*d.y )
        .attr( "width", w-1 ).attr( "height", w-1 )
        .attr( "fill", d => sc(d.val) );

    function update() {                                           4
        var nbs = [ [0,1], [0,-1], [ 1,0], [-1, 0],
                    [1,1], [1,-1], [-1,1], [-1,-1] ];
        return d3.shuffle( d3.range( n*n ) ).map( i => {
            var nb = nbs[ nbs.length*Math.random() | 0 ];
            var x = (data[i].x + nb[0] + n)%n;
            var y = (data[i].y + nb[1] + n)%n;
            data[i].val = data[ y*n + x ].val;
        } );
    }

    d3.interval( function() {                                     5
        update();
        svg.selectAll( "rect" ).data( data )
            .transition().duration(dt).delay((d,i)=>i*0.25*dt/(n*n))
            .attr( "fill", d => sc(d.val) ) }, dt );
}
1

Creates an array of n2 objects. Each object has a random value between 0 and 1, and also knowledge of its x and y coordinates in a square. (The odd d/n|0 expression is a shorthand way to truncate the quotient on the left to an integer: the bit-wise OR operator forces its operands into an integer representation, truncating the decimals in the process. This is a semi-common JavaScript idiom that is worth knowing.)

2

The object returned by d3.scaleQuantize() is an instance of a binning scale, which splits its input domain into equally sized bins. Here, the default input domain [0,1] is split into three equally sized bins, one for each color. (See Chapter 7 for more detail about scale objects.)

3

Binds the data set and then creates a rectangle for each record in the data set. Each data record contains information about the position of the rectangle, and the scale object is used to map each record’s value property to a color.

4

The actual update function that computes a new configuration when called. It visits each element of the array in random order. For each element, it randomly selects one of its eight neighbors and assigns the neighbor’s value to the current element. (The purpose of the arithmetic is to convert between the element’s array index and its (x, y) coordinates in the matrix representation, while taking into account periodic boundary conditions: if you leave the matrix on the left, you loop back in on the right and vice versa; same for top and bottom.)

5

The d3.interval() function returns a timer that invokes the specified callback at a configurable frequency. Here, it calls the update() function every dt milliseconds and then updates the graph elements with the new data. The updates are smoothed by a transition, which is delayed according to the position of the element in the array. The delay is short compared with the duration of the transition. The effect is that the update sweeps from top to bottom across the figure.

1 See the MDN Event Reference for more information.

2 If you want to access this in a callback, you must use the function keyword to define the callback; you cannot use an arrow function.

3 They are: screen, relative to the edge of the physical screen; client, relative to the edge of the browser window; and page, relative to the edge of the document itself. Due to the placement of the window on the screen, and to the scrolling of the page within the browser, these three will generally differ.

4 If you try to implement the current example without using the D3 drag facility, you may occasionally observe spurious user interface behavior. This is likely the browser’s default action interfering with the intended behavior. The remedy is to call d3.event.preventDefault() in the mousemove handler. See Appendix C for more information.

5 See http://mathworld.wolfram.com/LissajousCurve.html.

6 See http://mathworld.wolfram.com/VoterModel.html.

Get D3 for the Impatient now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.