Chapter 5. Generators, Components, Layouts: Drawing Curves and Shapes

In this chapter, we will explore some basic graphical building blocks provided by D3. In the previous chapters (Chapters 3 and 4), we learned how D3 manipulates the DOM tree in order to represent information visually, but we did not really concentrate on graphical objects. They will be the topic for the present chapter. This will also give us the opportunity to understand how different D3 facilities are structured and how they work together. We will also learn mechanisms to organize our own code for convenience and reuse.

Generators, Components, and Layouts

SVG provides only a small set of built-in geometric shapes (like circles, rectangles, and lines; see Appendix B). Anything else has to be built up painstakingly (and rather painfully) either from those basic shapes or by using the <path> element and its turtle graphics command language. How does D3 assist with this?

It is worth remembering that D3 is a JavaScript library for manipulating the DOM tree, not a graphics package. It doesn’t paint pixels, it operates on DOM elements. It should therefore come as no surprise that the way D3 facilitates graphics operations is by organizing and streamlining the way DOM elements are handled.

To produce complex figures, D3 employs three different styles of helper functions. These can be distinguished by the scale they act on: generators generate individual attributes, components create entire DOM elements, and layouts determine the overall arrangement of the whole figure (see Figure 5-1). Pay close attention, because none of them does quite what one might expect.

dfti 0501
Figure 5-1. Different D3 constructs to represent data visually: generators create a command string for <path> elements, components modify the DOM tree, and layouts add pixel coordinates and other information to the data set itself.
Generators

Generators are wrappers around the <path> element’s turtle graphics command language: they free the programmer from having to compose the cryptic command string. Generators typically consume a data set and return a string that is suitable for the <path> element’s d attribute. But they don’t create the <path> element: it’s up to the surrounding code to make sure such an element exists. We will see examples of generators in this chapter, and in Chapter 9.

Components

Components are functions that inject newly created elements into the DOM tree. They always take a Selection as argument, to indicate where in the DOM tree the new elements will be added. Often the selection will be a <g> element as a container for the newly created DOM elements. Components return nothing; they are usually invoked synthetically using the call() function of the destination Selection. Components do modify the DOM tree: they are the most “active” tools in the D3 toolbox. The axMkr in Example 2-6 was an example of a component, as was the drag-and-drop behavior component in Example 4-4.

Layouts

Layouts consume a data set and calculate the pixel coordinates and angles that graphical elements should have to represent the data set in some nontrivial way. For example, in Chapter 9 we will see a layout that consumes a hierarchical data set and calculates the pixel coordinates for each node in order to represent the data set as a tree diagram. Layouts return a data structure that can be bound to a `Selection`. But they don’t create or place any graphical elements themselves; they merely calculate the coordinates where the elements should go. It’s up to the calling code to make use of this information. We’ll see examples later in this chapter, and several more in Chapters 8 and 9.

All of these helpers are implemented as function objects. They perform their primary task when invoked as a function. But they also have state and expose an API of member functions to modify and configure their behavior. The general workflow is therefore as follows:

  1. Create an instance of the desired helper.

  2. Configure it as necessary (for example, by specifying accessor functions for the data set) using its member functions.

  3. Invoke it in the appropriate evaluation context.

Frequently, all three steps are bundled into one, so that the instance is created, configured, and evaluated as part of an attr(), call(), or data() invocation. (See Example 2-6 for some examples.)

Finally, keep in mind that the same patterns are available and convenient for your own code. The Component pattern, in particular, is frequently useful to reduce code duplication, even for tiny, single-page projects. (We’ll come back to this toward the end of this chapter.)

Symbols

Symbols are predefined shapes that can be used to represent individual data points in a graph (for example, in a scatter plot). This section introduces two different mechanisms to create symbols for use in an SVG. The first uses the D3 symbol generator and the SVG <path> element. The second employs the SVG <use> tag, which allows you to reuse a fragment of an SVG document elsewhere in the document.

Using D3 Built-Ins

D3 defines seven built-in symbol types for use with the d3.symbol() generator; see Figure 5-2. Example 5-1 demonstrates how you can create and use them; the resulting graph is shown in Figure 5-3.

dfti 0502
Figure 5-2. Predefined D3 symbol shapes. (If the name of the last symbol confuses you, try pronouncing it!)

The symbol generator is a bit different than the other D3 shape generators because it does not consume a data set. All you can configure are the symbol type and size (see Table 5-1). In particular, there are no provisions to fix the position of the symbol on the graph! Instead, all symbols are rendered at the origin, and you use SVG transforms to move them into their final positions. This is a common idiom when working with D3 and SVG; we will see it often. Some useful information on SVG transformations is in “SVG Transformations”.

dfti 0503
Figure 5-3. Using predefined symbols to represent data
Example 5-1. Commands for Figure 5-3
function makeSymbols() {
    var data = [ { "x":  40, "y":   0, "val": "A" },              1
                 { "x":  80, "y":  30, "val": "A" },
                 { "x": 120, "y": -10, "val": "B" },
                 { "x": 160, "y":  15, "val": "A" },
                 { "x": 200, "y":   0, "val": "C" },
                 { "x": 240, "y":  10, "val": "B" } ];

    var symMkr = d3.symbol().size(81).type( d3.symbolStar );      2
    var scY = d3.scaleLinear().domain([-10,30]).range([80,40]);   3

    d3.select( "#symbols" ).append( "g" )                         4
        .selectAll( "path" ).data(data).enter().append( "path" )  5
        .attr( "d", symMkr )                                      6
        .attr( "fill", "red" )
        .attr( "transform",                                       7
               d=>"translate(" + d["x"] + "," + scY(d["y"]) + ")" );

    var scT = d3.scaleOrdinal(d3.symbols).domain(["A","B","C"]);  8

    d3.select( "#symbols" )
        .append( "g" ).attr( "transform", "translate(300,0)" )    9
        .selectAll( "path" ).data( data ).enter().append( "path" )
        .attr( "d", d => symMkr.type( scT(d["val"]) )() )         10
        .attr( "fill", "none" )                                   11
        .attr( "stroke", "blue" ).attr( "stroke-width", 2 )
        .attr( "transform",                                       12
               d=>"translate(" + d["x"] + "," + scY(d["y"]) + ")" );
}
1

Define a data set, consisting of x and y coordinates and an additional “value.”

2

The d3.symbol() factory function returns a symbol generator instance. Here, we immediately configure the size of the symbols and select the star shape as the type to use. (The size parameter is proportional to the symbol’s area, not its radius.)

3

Conveniently, the x values in the data set will work as pixel coordinates, but we need a scale object to translate the y values to vertical positions. The inverted range() interval compensates for the upside-down graphical coordinates used by SVG.

4

Create an initial selection and append a <g> element. This <g> will hold the symbols on the left side of the graph and keep them separate from the ones on the right.

5

Bind the data and create a new <path> element for each data point.

6

Populate the d attribute of the previously created <path> element using the symbol generator. Because the symbol generator is already fully configured, we don’t need to do anything else at this point. The symbol generator is not evaluated, but instead is supplied as a function; it will automatically be invoked for each data point by the selection.

7

Move each newly created <path> element to its final position through an SVG transform, using the scale object to calculate the vertical offset.

8

For the right side of the graph, we will change the shape of the the symbol according to the third column in the data set. To do so, we need to associate the values from the data set with symbol shapes. An ordinal (or discrete) scale is essentially a hashmap that associates each value in the input domain with a value in the array d3.symbols of available symbol shapes. (See Chapter 7 to learn more about the different scale objects.)

9

Append a new <g> element for the second set of symbols, and shift it to the right. This shift will also apply to all children of the <g> element.

10

We can reuse the symbol generator instance created earlier. Here, every time the symbol generator is invoked, its type is set explicitly, based on the value in the data set.

11

For a change, the symbols are not filled; only the outlines are shown.

12

The transform to move each symbol into position is exactly the same as before. There is no need to change it, because the entire containing <g> element has been moved to avoid overprinting the stars on the left side.

This example demonstrates the basic techniques; many further variations are possible. For example, not only the shape but also the size and color of each symbol can be varied dynamically. To choose a color, the ordinal scale will come in handy again, like so:

d3.scaleOrdinal(["red","green","blue"]).domain(["A","B","C"]);

Or choose contrasting colors for interior and boundary (fill and stroke) to achieve a different effect.

Table 5-1. Methods of the symbol generator (sym is a symbol generator)
Function Description

d3.symbol()

Returns a new symbol generator. Unless configured otherwise, the generator will create a circle of 64 square pixels.

sym()

Returns a string, suitable as value of the d attribute of a <path> element, that will render a symbol of the desired shape.

sym.type(shape)

Sets the symbol shape. The argument should be one of the predefined symbol shapes shown in Figure 5-2. Returns the current value if called without argument.

sym.size(area)

Sets the symbol size. The argument is interpreted as the approximate area of the symbol (in pixels, before any scale transformation is applied). Returns the current value if invoked without an argument. The default size is 64 square pixels.

d3.symbols

An array (not a function), containing the predefined symbol shapes.

Custom Symbols

The way generators are used in Example 5-1 may appear a bit opaque, but there is really no magic here. Ultimately, all that happens is that the generator instance returns a string of commands using the <path> element’s command syntax. You can do that. For example, if you define the following function:1

function arrow() {
    return "M0 0 L16 0 M8 4 L16 0 L8 -4";
}

then you can replace step 6 in Example 5-1 with:

.attr( "d", arrow )

Now try applying a data-dependent rotation, using an appropriate SVG transform, to the generated <path> element! There are many more options.

SVG Fragments as Symbols

The <use> tag lets you reuse arbitrary SVG fragments within a document and hence provides an alternative for creating reusable symbols in SVG.2 It is recommended (but not required) that components intended for reuse are defined inside of the SVG document’s <defs> section. (The <defs> section must be part of the SVG document; do not place it into the header of the HTML page, for example!) Example 5-2 shows a document that defines a reusable component inside its <defs> section, and Example 5-3 shows how one might use it. (See Figure 5-4 for the result.)

Example 5-2. An SVG defining a symbol in the <defs> section (also see Example 5-3 and Figure 5-4).
<svg id="usedefs" width="275" height="100">
  <defs>                                                         1
    <g id="crosshair">                                           2
      <circle cx="0" cy="0" r="2" fill="none"/>                  3
      <line x1="-3" y1="0" x2="-1" y2="0" />
      <line x1="1" y1="0" x2="3" y2="0" />
      <line x1="0" y1="-1" x2="0" y2="-3" />
      <line x1="0" y1="1" x2="0" y2="3" />
    </g>
  </defs>
</svg>
1

Open a <defs> section as a child of the <svg> element.

2

Create a <g> element that will be the container for all elements making up the symbol, and assign an id to it. The <use> tag will refer to this symbol via the identifier specified here.

3

Add the graphical elements for the symbol. Remember that the symbol can be rescaled when it is being used, hence its absolute size is not important here.

Example 5-3. Creating symbols with <use> tags (also see Figure 5-4).
function makeCrosshair() {
    var data = [ [180, 1], [260, 3], [340, 2], [420, 4] ];        1

    d3.select( "#usedefs" )
        .selectAll( "use" ).data( data ).enter().append( "use" )  2
        .attr( "href", "#crosshair" )                             3
        .attr( "transform",                                       4
               d=>"translate("+d[0]+",50) scale("+2*d[1]+")" )
        .attr( "stroke", "black" )                                5
        .attr( "stroke-width", d=>0.5/Math.sqrt(d[1]) );          6
}
1

A minimal data set. The first component of each record will be used for the horizontal position, the second one for the symbol size.

2

Select the SVG element and bind data as usual, creating a <use> element for each record in the data set.

3

Set the href attribute for every newly created <use> element to the symbol identifier. The final element in the page will look something like: <use href="#crosshair" ...>.

4

Use the transform attribute to choose the symbol’s position and size.

5

Set the stroke color. It is necessary to do so explicitly, because the default value of the stroke attribute is none!

6

Set the stroke width. To obtain strokes of roughly equal width in the figure, the stroke width must be chosen so as to cancel the effect of the earlier scale transformation.

dfti 0504
Figure 5-4. Creating symbols with <use> tags (also see Example 5-3)

Of course, the contents of the <defs> section is no different than the rest of the document, which means that it, too, can be generated dynamically using D3. For example, the following snippet creates a diagonal cross (also called a “saltire”) that can be invoked through <use> tags:3

var d = d3.select("svg").append("defs")
    .append("g").attr("id", "saltire");
d.append("line")
    .attr("x1",-1).attr("y1",1).attr("x2",1).attr("y2",-1);
d.append("line")
    .attr("x1",-1).attr("y1",-1).attr("x2",1).attr("y2",1);

Yet another way is to load the appropriate SVG from a file and insert it into the current document (see Chapter 6 for an example).

Lines and Curves

Drawing lines and curves follows precisely the workflow outlined at the beginning of this chapter: you instantiate a generator, feed it a data set, and then invoke it to obtain a string suitable for the d attribute of a <path> element.

dfti 0505
Figure 5-5. Creating lines to connect dots. On the right side, one of the data points has been marked as “undefined” and breaks the line into two segments.
Example 5-4. Creating lines (see Figure 5-5)
function makeLines() {
    // Prepare a data set and scale it properly for plotting
    var ds = [ [1, 1], [2, 2], [3, 4], [4, 4], [5, 2],
               [6, 2], [7, 3], [8, 1], [9, 2] ];
    var xSc = d3.scaleLinear().domain([1,9]).range([50,250]);
    var ySc = d3.scaleLinear().domain([0,5]).range([175,25]);
    ds = ds.map( d => [xSc(d[0]), ySc(d[1])] );                   1

    // Draw circles for the individual data points
    d3.select( "#lines" ).append( "g" ).selectAll( "circle" )     2
        .data( ds ).enter().append( "circle" ).attr( "r", 3 )
        .attr( "cx", d=>d[0] ).attr( "cy", d=>d[1] );

    // Generate a line
    var lnMkr = d3.line();                                        3
    d3.select( "#lines" ).append( "g" ).append( "path" )          4
        .attr( "d", lnMkr(ds) )                                   5
        .attr( "fill", "none" ).attr( "stroke", "red" );          6
}
1

Here, we apply the scale operations to the entire data set ahead of time, rather than invoking them on an as-needed basis later, in order to keep the subsequent code simpler.

2

We use <circle> elements to mark the individual data points. (We could have used symbols instead, but the transform syntax required to move them into position is clumsier.)

3

Generate an instance of the line generator.

4

Remember that a line is ultimately realized as a <path> element, hence we must first create a <path> element that will subsequently be configured.

5

Invoke the line generator, with the data set as argument.

6

Fix the presentational attributes.

Three aspects of the line generator are configurable (see Table 5-2). Using the x() and y() methods, you can set the accessor functions that pick out the x and y coordinates from the data set. The accessors are called with three arguments:

function( d, i, data ) { ... }

where d is the current record in the data set, i is its index, and data is the entire data set itself. If you don’t supply your accessors, then the default is used, which selects the first and second column from a multidimensional array. (This was done in Example 5-4.)

Another configuration option allows you to mark certain points as “undefined,” by passing an accessor to the defined(accessor) function. The accessor must return a Boolean value; if it is false for some data points, then the generated line will exclude those points, resulting in a break that separates the line into segments. An example should make this clear. If you replace the line:

var lnMkr = d3.line();

in Example 5-4 with:

var lnMkr = d3.line().defined( (d,i) => i==3 ? false:true );

then the point at index position 3 is treated as undefined, resulting in the graph on the right side of Figure 5-5. You can use this feature to exclude certain data points, for example, those that fall outside the desired plot range or where some quantity changes discontinuously, without having to remove them from the data set itself.

The shape and nature of the curve that is drawn to connect the individual points can be changed through curve factories. (They will be discussed in the next section.) Finally, be aware that the line generator will connect points in the sequence in which they appear in the data set. This implies that you may need to sort points (for example, by their x coordinates) before passing them to the line generator. The following snippet uses the sort() function of JavaScript’s Array type to sort the data set from Example 5-4 by the x coordinates of the data points:

ds = ds.sort( (a,b) => a[0]>b[0] )

As usual, the line generator’s member functions in Table 5-2 can act both as getters and setters. When called without arguments, they act as getters and return the current value. When called as setters, they return the current generator instance, in order to allow method chaining.

Table 5-2. Methods of the line generator (mkr is a line generator)
Function Description

d3.line()

Returns a new line generator instance.

mkr(data)

When called with a data set, returns a string, suitable for the d attribute of a <path> element, that will draw a line through the points in the data set.

mkr.x(accessor), mkr.y(accessor)

Set accessor functions for the x and y coordinates of each data point. Each accessor will be called with three arguments: the current record d in the data set, its index i, and the entire data set data. The accessors must return the x or y coordinates for the current data point, respectively. The default accessors select the first two columns in a two-dimensional data set.

mkr.defined(accessor)

Sets an accessor that allows to mark arbitrary points as undefined. The accessor is called with same three arguments as the coordinate accessors, but must return a Boolean value; points for which the accessor returns false are treated as undefined.

mkr.curve(curve)

Sets the curve factory to be used.

Built-In Curves

In addition to straight lines, D3 offers a variety of other curve shapes to connect consecutive points—and you can also define your own. You choose a different curve shape by passing the appropriate curve factory to the line generator, like so:

var lnMkr = d3.line().curve( d3.curveLinear );

The built-in curve factories (see Figure 5-6) fall into two major groups:

  • Curves that are completely determined by the data set and that pass exactly through all data points (see Table 5-3).

  • Curves that depend on an additional curvature or stiffness parameter and that may not pass exactly through the data points (see Table 5-4).

The built-in curves are primarily intended for information visualization; they were not developed for scientific curve plotting. Curves are drawn in the order of the points in the data set; data points are not reordered based on the x coordinates! The built-in curves make no direct provisions for curve fitting, for filtering noisy data, or for weighting data points according to their local error. If you need such functionality, you will have to implement it yourself, for example, by implementing a custom curve generator (see the next section), or by first processing the input data separately. The D3 Reference Documentation contains additional details and references to the original literature.

dfti 0506
Figure 5-6. Built-in curve factories. The values of the adjustable parameter for the bottom row are 0.0 (blue), 0.25, 0.5, 0.75, and 1.0 (red).

Curves without an adjustable parameter

In addition to straight lines and step functions, D3 also provides several spline implementations to connect consecutive points. Natural splines match direction and curvature where line segments meet, and have zero curvature at the ends of the data set. The curve created by d3.curveMonotoneX does not overshoot the data points in the vertical when passing through the points in the horizontal direction (similarly for d3.curveMonontoneY).

Table 5-3. Factories for curves without an adjustable parameter
Straight lines Splines Step

d3.curveLinear

d3.curveNatural

d3.curveStep

d3.curveLinearClosed

d3.curveMonotoneX

d3.curveStepAfter

d3.curveMonotoneY

d3.curveStepBefore

Curves with an adjustable parameter

Both cardinal and Catmull-Rom splines pass through the data points exactly, but relax the condition that the curvatures should match at all line segment joints. Instead, they impose additional constraints on the local tangents at the joints. Both depend on an adjustable parameter that controls their shape; this parameter should take values between 0 and 1. Cardinal and Catmull-Rom splines are identical to each other if this parameter is zero; they differ in their behavior if the parameter value increases. The curve factories provide methods to set the parameter, for example:

d3.curveCardinal.tension(0.5);
d3.curveCatmullRomClosed.alpha(0.25);

The curves created by d3.curveBundle are not required to pass through the data points exactly. When the adjustable parameter is zero, the resulting curve coincides with the curve generated by d3.curveBasis.

Except for d3.curveBundle, the adjustable curve types provide open and closed variants (such as d3.curveCardinalOpen or d3.curveBasisClosed). The open curve factories use the end points of the data set for the determination of the curve shape, but do not extend the drawn curve to the actual end points (the curves stop at the second-to-last point). For the closed curves, the ends of the data set are connected, so that the first and last point are treated as neighbors. The resulting line forms a geometrically closed curve.

Table 5-4. Factories for curves that depend on an adjustable parameter
Curve factory Adjustable parameter Default value

d3.curveCardinal

cardinal.tension(t)

0

d3.curveCatmullRom

catrom.alpha(a)

0.5

d3.curveBundle

bundle.beta(b)

0.85

d3.curveBasis

Custom Curves

If you are dissatisfied with the built-in curves, you can provide your own curve algorithms by supplying a custom curve factory to the line generator—for example, to implement a fitting or smoothing method.4 A curve factory is a function that accepts a single argument and returns an object that implements the curve API. When D3 invokes the factory function, it supplies a d3.path object as the argument. The d3.path facility exposes a turtle graphics interface similar to the command language of the <path> element. Your implementation of the curve API must populate this object according to your algorithm. The curve API, which your custom code must implement, consists of three functions: lineStart(), lineEnd(), and point(x, y)—their semantics will be explained in a moment. Eventually, D3 will turn the path object into a string that can be used to populate the d attribute of a <path> element.

Let’s consider a very simple example, just to explain the mechanism. Example 5-5 implements a curve factory that draws a horizontal line at the median of the vertical positions of all the data points for each line segment. It behaves exactly like the built-in factories; for example, you could use it in Example 5-4 like so:

var lnMkr = d3.line().curve( curveVerticalMedian );
Example 5-5. A simple curve factory
function curveVerticalMedian( context ) {
    return {
        lineStart: function() {
            this.data = [];                                       1
        },

        point: function( x, y ) {
            this.data.push( [x, y] );                             2
        },

        lineEnd: function() {
            var xrange = d3.extent( this.data, d=>d[0] );         3
            var median = d3.median( this.data, d=>d[1] );

            context.moveTo( xrange[0], median );                  4
            context.lineTo( xrange[1], median );                  5
        }
    };
}
1

The lineStart() function is called at the beginning of every line segment (remember that every point data point marked as “undefined” ends the current and forces a new line segment). Here, an array is initialized that will hold the data points.

2

The point(x, y) function is called for every data point with the coordinates of that point. In this case, the coordinates of each point are simply appended to the end of the array.

3

The lineEnd() function is called at the end of each line segment. Now that all the data points for the current line segment have been collected, the horizontal extent of the data set and the median of the vertical positions can be found, and with this information, we are now ready to draw the desired line.

4

The context variable is a d3.path instance, which is supplied to the curve factory by the D3 infrastructure. This object exposes functions similar to the command language of the <path> element. All drawing has to be done using this object. Here, we invisibly place the pen at the beginning of the line and…

5

… draw a visible line to its end.

This is the basic workflow. With a little more work, you can change the lineEnd() function to calculate and draw, for example, a linear regression line, a locally weighted interpolation such as LOESS, or a kernel density estimate.5

If the resulting curve is not a straight line, then it will generally be necessary to build it up from individual pieces in a loop. In the following snippet, the horizontal plot interval is broken into 100 steps:

for( var t = xmin; t <= xmax; t += 0.01*(xmax-xmin) ) {
    context.lineTo( t, y(t) );
}

Here, xmin and xmax are the end points of the plot interval, and y(t) is the computed value of the curve at position t.

To calculate the median or a linear regression, the entire data set must be known before computation and drawing can begin. Other curves can be calculated and drawn incrementally—straight lines connecting consecutive points, for example. In this case, the main logic moves from the lineEnd() function to the point() function: for each new data point, the next part of the curve is calculated and the appropriate d3.path methods are called to draw it.

Circles, Arcs, and Pie Charts: Working with Layouts

So far, we have not encountered an example for a D3 layout yet. Layouts consume a data set and calculate where graph elements should be placed to visualize the data. They return a data structure containing this information, but don’t actually create or place any DOM elements; instead, it is up to the surrounding code to do this. Some D3 generators are specifically designed to work with the data structure returned by a particular layout. Although both can be used separately, understanding the layout helps to understand the generator.

The D3 pie layout provides a particularly clear example to demonstrate how this combined process works, and I will describe it here in some detail. Pie charts are sometimes frowned upon, but the D3 pie layout is of interest nevertheless, not only to explain the D3 layout workflow, but also as an extremely versatile method for creating any sort of circular graph, not just classical pie charts. For example, it’s a fun exercise to use the pie layout to create color wheels in order to explore different color spaces! (See Chapter 8 for color spaces available in D3; Chapter 9 discusses D3 layouts for tree diagrams.)

Example 5-6 defines a data set describing, say, an election outcome: for each candidate, it gives the name and number of votes. Figure 5-7 shows a pie chart of this data set.

dfti 0507
Figure 5-7. A pie chart (see Example 5-6)
Example 5-6. Using the pie layout and arc generator (see Figure 5-7)
function makePie() {
    var data = [ { name: "Jim", votes: 12 },
                 { name: "Sue", votes:  5 },
                 { name: "Bob", votes: 21 },
                 { name: "Ann", votes: 17 },
                 { name: "Dan", votes:  3 } ];

    var pie = d3.pie().value(d=>d.votes).padAngle(0.025)( data ); 1

    var arcMkr = d3.arc().innerRadius( 50 ).outerRadius( 150 )    2
        .cornerRadius(10);

    var scC = d3.scaleOrdinal( d3.schemePastel2 )                 3
        .domain( pie.map(d=>d.index) )                            4

    var g = d3.select( "#pie" )                                   5
        .append( "g" ).attr( "transform", "translate(300, 175)" )

    g.selectAll( "path" ).data( pie ).enter().append( "path" )    6
        .attr( "d", arcMkr )                                      7
        .attr( "fill", d=>scC(d.index) ).attr( "stroke", "grey" );

    g.selectAll( "text" ).data( pie ).enter().append( "text" )    8
        .text( d => d.data.name )                                 9
        .attr( "x", d=>arcMkr.innerRadius(85).centroid(d)[0] )    10
        .attr( "y", d=>arcMkr.innerRadius(85).centroid(d)[1] )
        .attr( "font-family", "sans-serif" ).attr( "font-size", 14 )
        .attr( "text-anchor", "middle" );
}
1

This line creates an instance of the pie layout, configures it, and invokes it on the data set, all in one step. The returned data structure pie is an array of objects with one object for each record in the original data set. Each object contains a reference to the original data, start and end angles of the associated pie slice, and so on.

2

Creates and configures an arc generator (but does not invoke it).

3

Creates a scale object that associates a color with each pie element. The colors are drawn from the built-in scheme d3.schemePastel2, which defines a set of gentle colors, suitable as background colors, but that nevertheless provide good visual contrast to each other.

4

Ordinal scales look up objects by their string representation. Unfortunately, JavaScript’s default toString() method does not return a unique identifier for an object, only a generic constant. For this reason, we can’t map from the elements of the pie array directly and have to choose a uniquely identifying member of each element.

5

Selects the destination element in the page, appends a <g> element as the common container of the chart components, and moves it into position. (The pie chart generated by the arc generator will be centered at the origin.)

6

Bind the pie data structure…

7

… and invoke the arc generator on each element.

8

The remaining commands create the text labels in each pie slice.

9

Each object in the pie array has a member data that contains a reference to the corresponding record in the original data set.

10

The centroid() function on the arc generator returns the coordinates of a location that is centered inside each pie slice as a suitable position for a label. Here I change the inner radius of the arc generator in order to push the labels further toward the rim.

This is all. If you compare the API for the pie and the arc generator (Tables 5-5 and 5-6), you will notice how closely the default settings for the arc generator match the data structure returned by the pie layout, leading to the rather compact code in Example 5-6.

Table 5-5. Methods of the pie chart layout operator (mkr is an instance of the layout operator)
Function Description

d3.pie()

Returns a new layout operator in default configuration.

mkr( data )

Takes an array of arbitrary records and produces an array of objects. Each output object represents one pie slice and has the following members:

  • data : a reference to the corresponding record in the original data set

  • value : the numeric value represented by the current pie slice

  • index : a positive integer giving the position of the current segment when following the sequence of segments in order of increasing angle around the chart

  • startAngle, endAngle : beginning and end of the pie slice, in radians, measured clockwise from the top (noon) position

  • padAngle : padding for the current pie slice

The segments in the returned array are always sorted according to the sequence of values in the input data set; they are not necessarily sorted in order of ascending angular positions in the chart.

mkr.value( arg )

Numeric value or accessor function to return the relevant numerical value that the current slice represents. Defaults to: d => d.

mkr.sort( comparator )

Sets the comparator that is used to sort elements of the original data set. The sorting affects the assignment of elements to positions around the chart; it does not affect the order of segments in the returned array.

mkr.sortValues( comparator )

Sets the comparator that is used to sort elements of the original data set by their value. The sorting affects the assignment of elements to positions around the chart; it does not affect the order of segments in the returned array. By default, slices are sorted by value in descending order.

mkr.startAngle( arg ), mkr.endAngle( arg )

Numeric value or accessor function to return the overall start and end angle of the pie chart.

mkr.padAngle( arg )

Numeric value or accessor function to return any padding to be inserted between adjacent slices.

The arc generator can be used by itself to draw arbitrary circle segments even if they are not used as part of a pie chart, but in this case it is up to the user to provide the necessary information in a suitable form. The arc generator will fail unless start and end angles and inner and outer radius have been fixed; there are no default values. These parameters can be set either through API calls or be provided as an object when the generator is evaluated. In the latter case, the parameters are extracted from the object by the configured accessor functions. These techniques may also be mixed (see Example 5-7).

Example 5-7. Different ways to configure the arc generator. In all cases, g is a suitable <g> selection.
// API configuration
var arcMkr = d3.arc().innerRadius( 50 ).outerRadius( 150 )
    .startAngle( 1.5*Math.PI ).endAngle( 1.75*Math.PI );
g.append( "path" ).attr( "d", arcMkr() );

// Object with default accessors
var arcSpec = { innerRadius: 50, outerRadius: 150,
                startAngle: 0, endAngle: 0.25*Math.PI };
g.append( "path" ).attr( "d", d3.arc()( arcSpec ) );

// Mixed, nondefault accessors
var arcMkr = d3.arc().innerRadius( 50 ).outerRadius( 150 )
    .startAngle( d => Math.PI*d.start/180 )
    .endAngle( d => Math.PI*d.end/180 );
g.append( "path" ).attr( "d", arcMkr( { start: 60, end: 90 } ) );
Table 5-6. Methods of the arc generator (mkr is an arc generator)
Function Description

d3.arc()

Returns an arc generator in default configuration.

mkr( args )

Returns a string suitable for the <path> element’s d attribute, provided the start and end angle and the inner and outer radius have been defined either through API calls or by providing an object as the argument that can be interpreted by the configured accessors. Values set through API calls take precedence.

mkr.innerRadius( arg ), mkr.outerRadius( arg )

Sets the inner and outer radius to a constant value, or sets an accessor that extracts and returns them from the arguments provided when the generator is invoked. Defaults: d => d.innerRadius, d => d.outerRadius.

mkr.startAngle( arg ), mkr.endAngle( arg )

Sets the start and end angle to a constant value, or sets an accessor that extracts and returns them from the arguments provided when the generator is invoked. Angles are measured clockwise from the top (noon) position and must not be negative. Defaults: d => d.startAngle, d => d.endAngle.

mkr.centroid()

Returns the coordinates of a location that is centered inside the generated slice as a two-element array.

mkr.cornerRadius( arg )

Sets the radius by which the corners of a slice will be rounded, or sets an accessor that extracts and returns them. Default: 0.

Other Shapes

D3 includes facilities for the generation and layout of other shapes:

  • The d3.area() generator is related to the d3.line() generator, except that it creates areas that are bounded by two boundary lines.

  • The d3.lineRadial() and d3.areaRadial() generators work with polar coordinates to make circular line and area plots.

  • The d3.stack() layout is intended to be used with the d3.area() generator for the creation of stacked graphs (where several components are graphed together in such a way that the cumulative value of a set of quantities is displayed).

  • The d3.chord() layout shows the mutual flows between nodes in a network as a circular arrangement. It is intended to be used together with the d3.arc() and the specialized d3.ribbon() generator.

  • The d3.links() generator is used to draw the branches in tree diagrams of hierarchical data sets. It is intended to be used together with the d3.tree() and d3.cluster() layouts.

  • The d3.pack(), d3.treemap(), and d3.partition() layouts prepare hierarchical data to be displayed in containment hierarchies.

The last two items of this list will be discussed in Chapter 9. Check the D3 Reference Documentation for information about the other items.

Writing Your Own Components

Compared to the variety of generators and layouts, D3 does not contain many built-in components. (The axis facility from Example 2-6 is one example, and the drag-and-drop behavior from Example 4-4 is another.) But components are the primary means to organize and simplify your own work.

Components are functions that take a Selection as argument and do something to it.6 They return nothing; all their results are side effects.

Writing a component is useful whenever you want to repeatedly create several DOM elements together, in particular when their positioning relative to each other is important. But a component may also simply be a way of saving keystrokes on a recurring set of commands, or a method to encapsulate a bit of messy code. In many ways, components are to the DOM tree what functions are to code: a reusable set of actions that are always performed together.

A Simple Component

Let’s say that we want to use, in multiple locations in a graph, a rectangle with rounded corners and a text label centered inside of it (see Figure 5-8). In this kind of situation, it is convenient to keep the relative positioning of the parts within the component (text and border) separate from the positioning of the entire component itself. The sticker() function in Example 5-8 creates both the rectangle and the text inside of it centered at the origin. It is then up to the calling code to move both of them together to the final position.

dfti 0508
Figure 5-8. A reusable component
Example 5-8. Commands for Figure 5-8
function sticker( sel, label ) {                                  1
    sel.append( "rect" ).attr( "rx", 5 ).attr( "ry", 5 )          2
        .attr( "width", 70 ).attr( "height", 30 )
        .attr( "x", -35 ).attr( "y", -15 )
        .attr( "fill", "none" ).attr( "stroke", "blue" )
        .classed( "frame", true );                                3

    sel.append( "text" ).attr( "x", 0 ).attr( "y", 5 )            4
        .attr( "text-anchor", "middle" )
        .attr( "font-family", "sans-serif" ).attr( "font-size", 14 )
        .classed( "label", true )
        .text( label ? label : d => d );                          5
}

function makeSticker() {
    var labels = [ "Hello", "World", "How", "Are", "You?" ];
    var scX = d3.scaleLinear()
        .domain( [0, labels.length-1] ).range( [100, 500] );
    var scY = d3.scaleLinear()
        .domain( [0, labels.length-1] ).range( [50, 150] );

    d3.select( "#sticker" )                                       6
        .selectAll( "g" ).data( labels ).enter().append( "g" )
        .attr( "transform",
               (d,i) => "translate(" + scX(i) + "," + scY(i) + ")" )
        .call( sticker );

    d3.select( "#sticker" ).append( "g" )                         7
        .attr( "transform", "translate(75,150)" )
        .call( sticker, "I'm fine!" )
        .selectAll( ".label" ).attr( "fill", "red" );             8
}
1

A component is simply a function that takes a Selection instance as its first argument. The remaining parameters are arbitrary.

2

Create and configure the rectangle as a child of the supplied selection. The rectangle is centered at the origin.

3

Assign a CSS class to the rectangle: this will make it easier to identify it later for styling. The second parameter to classed() is important: true indicates that the class name should be assigned to the element, whereas false indicates that the class name should be removed.

4

Create and configure the text element and assign it a class name as well.

5

If a second argument has been supplied when the sticker() function was called, use it as the label. Otherwise, use the data bound to the current node. This makes it possible to use this component regardless of whether data is bound to the target selection or not.

6

To use this component with bound data, create a <g> element for each data point as usual, and then invoke the sticker() function using call(). This will execute the sticker() function, while supplying the current selection as the first argument. In this case, the “current selection” contains the newly created <g> elements (with their bound data).

7

To use this component without bound data, again append a <g> element as the container for the sticker, and invoke sticker() via call(), but this time you must supply a label explicitly. The call() facility will simply forward any arguments past the first one when invoking the supplied function.

8

The CSS class name makes it easy to select part of the component to change its appearance. Keep in mind, however, that selectAll() returns a new selection: any subsequent calls in the chain of function calls will only apply to the elements that matched the ".labels" selector!

Working with Components

A few general comments about working with components:

  • A component is usually created inside of a <g> element as the common parent to all parts of the component: for example, so that the entire component can be moved to its final location through an SVG transformation applied to the containing <g> element. This <g> element is usually not created by the component, but by the calling code. If this seems surprising, keep in mind that the component does not return the elements it creates, but adds them directly to the DOM tree. To do so, it needs to know where to add them, and the calling code must supply this information—hence the surrounding code creates the <g> element as the destination for the component to add its results.

  • Although a component can be invoked using regular function call syntax, with the destination Selection as the first argument, it is more idiomatic to invoke it “synthetically” using the call() function. The call() function will automatically inject the destination Selection as the first argument and return the same Selection instance, thus enabling method chaining. If you need to gain access to the new DOM elements that were created by the component, use selectAll() with an appropriate selector on the Selection returned by call(). The typical call sequence therefore looks like this:

    d3.select("svg").append("g").attr( "transform", ... )
        .call( component )
        .selectAll( "circle" ).attr( "fill", ... )...

    Also see the very end of Example 5-8 for an example.

  • Although it is possible to pass in appearance options (like color and so on) as additional parameters to the component, it is usually more idiomatic to apply them later on using the usual mechanisms of the Selection API (as was done toward the end of Example 5-8). This has the advantage that the appearance of the component is not fixed by its author, but can be changed—as desired—by the user.

A Component to Save Keystrokes

Whereas the sticker component in the previous section can reasonably claim to be reusable, a component can also make sense as nothing more than an ad hoc labor-saving device to save keystrokes in a local scope. For example, imagine that you need to add <text> elements in several spots, all of which should use different text alignments and font sizes. Let’s also assume a situation where it is not possible or practical to collect these presentational attributes in a stylesheet or on a common parent. An ad hoc component like the following can relieve you from having to type the attribute names and function calls for every <text> element explicitly:

function texter( sel, anchor, size ) {
    return sel.attr( "text-anchor", anchor )
        .attr( "font-size", size )
        .attr( "font-family", "sans-serif" );
}

And you use it simply as shorthand where appropriate:

d3.select( ... )
    .append( "text" ).call( texter, "middle", 14 ).text( ... );

Similar ad hoc components might be useful to set the fill and stroke colors of an object in one fell swoop, or to create a circle with its center coordinates and radius in a single call.

SVG Transformations as Components

SVG transformations are extremely useful—but, unfortunately, they are also a bit verbose, in particular when you need to build up the argument string dynamically. A labor (and space) saving device would be welcome! (This section is probably best skipped on first reading.)

It’s easy enough to write a component for that purpose (in this section, I’ll restrict myself to translations for simplicity; extensions to more general SVG transformations are straightforward):

function trsf( sel, dx, dy ) {
    return sel.attr( "transform", "translate("+dx+","+dy+")" );
}

And you would invoke it like any other component:

d3.select( ... ).append("g").call(trsf, 25, 50).append( ... )...

This is fine as far as it goes, but what if the offsets should depend on data bound to a selection? What we want to do is this (compare, for instance, Example 5-8):

d3.select( ... ).data( data ).enter().append( "g" )
    .call( trsf, (d,i)=>..., (d,i)=>... ).append( "circle" )...

But the simple trsf() component just introduced will not work in that situation. In order to handle accessor functions as arguments, the component must distinguish whether these arguments are functions or constants, and evaluate them in the former case. One problem that arises if we want to stay within the published Selection API (instead of poking under the hood) is that the API functions only allow for one accessor function to be evaluated simultaneously, but our trsf() takes two. Hence we need to evaluate the first accessor for all elements of the selection, store the results, then evaluate the second accessor, and finally combine the intermediate results to create the actual transform attribute value. The following code makes use of the d3.local() facility, intended for just this kind of application. It is essentially a hashmap, but expects a DOM Node as key. In this way, it is possible to store an intermediate value per Node:

function trsf( sel, dx, dy ) {
    var dxs = d3.local(), dys = d3.local();
    sel.each( function(d,i) {
        dxs.set( this,
                 typeof dx==="function" ? dx(d,i) : dx||0 ) });
    sel.each( function(d,i) {
        dys.set( this,
                 typeof dy==="function" ? dy(d,i) : dy||0 ) });

    return sel.attr( "transform", function() {
        return "translate(" +
            dxs.get(this) + "," + dys.get(this) + ")"} );
}

A simpler but less clean method is to store the intermediate result in the node itself by adding additional “bogus” properties to it:

sel.each( function(d,i) {
    this.bogus_dx = typeof dx==="function" ? dx(d,i):dx||0 } );

Either way, the trsf() component can now be called as intended. For example, the following code could be added to Example 5-8:

var vs = [ "This", "That" ];
d3.select( "#sticker" ).append( "g" )
    .selectAll( "g" ).data( vs ).enter().append( "g" )
    .call( trsf, (d,i) => 300 + scX(i), (d,i) => scY(i) )
    .call( sticker );

1 Internally, the D3 symbol generator uses an HTML5 <canvas> element for performance reasons, but there is no strict need to do so.

2 Although current browsers don’t seem to have a problem with the <use> tag, I have found that not all SVG tools handle it properly. Be aware.

3 Another way to create such a symbol is to rotate d3.symbolCross by 45 degrees using an SVG transformation!

4 This section is probably best skipped on first reading.

5 See, for example, Data Analysis with Open Source Tools by Philipp K. Janert (O’Reilly).

6 Despite the name, components are not things, they are actions, although often the action results in a thing being added to the DOM tree.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset