Pie in the Sky, with D3

The world is divided in beer drinkers and wine lovers. Let’s illustrate this with a pie chart, using D3 for all the rendering work. First, we need some data:

var data = [
    {type: 'beer', percentage: 67},
    {type: 'wine', percentage: 33}
];

The pie data for plotting can be created with D3’s pie layout.

var pieLayout = d3.layout.pie();
pieLayout.value(function(d) {
    return d.percentage;
});

var pie = pieLayout(data);

var pieRadius = 200;
var arc = d3.svg.arc();
arc.outerRadius(pieRadius);

By default, the slices will start from the 12 o’clock position. If you want to start them from some other position, you should change the arc angles. For example, the following code lets the pie slices start at the 3 o’clock position.

arc.outerRadius(pieRadius);
   .startAngle(function(d) { return d.startAngle + Math.PI / 2; })
   .endAngle(function(d) { return d.endAngle + Math.PI / 2; });

This doesn’t change the underlying data, so you’ll have change the angle when calculating label positions later on. For the sake of this blog we’ll stick to the default 12 o’clock position.

We now only have to add all the SVG content.

var yScaleFactor = 0.7;

var svg = d3.select('body')
        .append('svg')
        .attr('width', 700)
        .attr('height', 500);

svg.append('g')
        .attr('transform', 'translate(350, 250) scale(1, ' + yScaleFactor + ')')
        .selectAll('path')
        .data(pie)
        .enter()
        .append('path')
        .attr('d', arc)
        .attr('class', function(d) { return d.data.name; })
        .attr('stroke', 'black');

Note that we have squashed the pie chart in y direction, which is merely for aesthetic reasons.

As we have added classes to the generated path elements we can style their fill in the css stylesheet.

.beer {
    fill: gold;
}

.wine {
    fill: darkred;
}

It would be nice to to have labels along the various pie slices, and for that we can use a few helper functions.

var textX = function(startAngle, endAngle) {
    return (pieRadius + textDistance) * Math.sin(0.5 * (startAngle + endAngle));
};

var textY = function(startAngle, endAngle) {
    return -yScaleFactor * (pieRadius + textDistance) * Math.cos(0.5 * (startAngle + endAngle));
};

var textAnchor = function(startAngle, endAngle) {
    return Math.sin(0.5 * (startAngle + endAngle)) >= 0 ? 'start' : 'end';
};

var verticalTextShift = function(startAngle, endAngle) {
    return Math.cos(0.5 * (startAngle + endAngle)) >= 0 ? 0 : '1ex';
};

Clearly we need the start and end angle of each pie slice. But these are supplied as fields of the pie data objects. Hence adding text becomes fairly straightforward:

svg.append('g')
        .attr('transform', 'translate(250, 250)')
        .selectAll('text')
        .data(pie)
        .enter()
        .append('text')
        .attr('x', function(d) {
                  console.log(d.data.name + ': '); return textX(d.startAngle, d.endAngle);
              })
        .attr('y', function(d) {
                  return textY(d.startAngle, d.endAngle);
              })
        .attr('text-anchor', function(d) {
                      return textAnchor(d.startAngle, d.endAngle);
                  })
        .append('tspan')
        .attr('dy', function(d) {
                      return verticalTextShift(d.startAngle, d.endAngle);
                  })
        .text(function(d) {
                  return d.data.name;
              });

Let’s add some interactivity! Assume the page contains the following table (which could be created with D3, if you want to).

<table>
    <tr>
        <th>Drink</th>
        <th>Percentage</th>
    </tr>
    <tr class="beer">
        <td>Beer</td>
        <td>67</td>
    </tr>
    <tr class="wine">
        <td>Wine</td>
        <td>33</td>
    </tr>
</table>

Whenever the cursor is hovering over a pie slice the corresponding table should be highlighted. To achieve this we first define a suitable CSS rule.

.selected {
    font-weight: bold;
    color: darkred;
}

And then we add event listeners to the generated path elements by modifying the code as follows.

svg.append('g')
        // ...
        .selectAll('path')
        .data(pie)
        .enter()
        .append('path')
        // ...
        .on('mouseover', function(d) {
                    d3.select('tr.' + d.data.name)
                            .classed('selected', true);
                  })
        .on('mouseout', function(d) {
                    d3.select('tr.' + d.data.name)
                            .classed('selected', false);
                });

There is a caveat: If you paint labels (such as a percentage value) onto the pie slices they will consume the hover events. To get rid of this, add a css style pointer-events: none to such labels.

Finally, let’s add a drop shadow. This can be done by adding a filter. If we were writing raw SVG, this could be done as follows.

<defs>
    <filter id="dropshadow" x="-10%" y="-10%" width="120%" height="120%">
        <feOffset result="offOut" in="SourceGraphic" dx="-3" dy="7" />
        <feColorMatrix result="matrixOut" in="offOut" type="matrix"
                       values="0.2 0 0 0 0 0 0.2 0 0 0 0 0 0.2 0 0 0 0 0 1 0" />
        <feGaussianBlur result="blurOut" in="matrixOut" stdDeviation="5" />
        <feBlend in="SourceGraphic" in2="blurOut" mode="normal" />
    </filter>
</defs>

It is beyond the scope of this post to explain this code, but one or two remarka might be in order:

  • It is absolutely crucial to set the x, y, width and height attributes of the filter element, as these define the filter “canvas”. If you don’t set these, part of the pie and the drop shadow will be cut off.
  • Note that the filter element has an id attribute. We’ll need that in a moment.
  • The value of the feColorMatrix’ values attribute is a 5×4 matrix transforming a pixel RGBA value. In the example, the RGB values are all diminished by a factor 0.2 and the A value is left unchanged.

See J.D. Eisenberg & A. Bellamy-Royds: SVG Essentials (2nd Edition), O.Reilly, 2014 for more details.

The filter thus defined is applied as follows.

<g filter="url(#dropshadow)">...</g>

As we create the svg element dynamically with D3, we need to add the filter dynamically as well:

var defs = svg.append('defs');

var filter = defs.append('filter')
        .attr('id', 'dropshadow')
        .attr('x', '-10%')
        .attr('y', '-10%')
        .attr('width', '120%')
        .attr('height', '120%');

filter.append('feOffset')
        .attr('result', 'offOut')
        .attr('in', 'SourceGraphic')
        .attr('dx', -3)
        .attr('dy', 7);

filter.append('feColorMatrix')
        .attr('result', 'matrixOut')
        .attr('in', 'offOut')
        .attr('type', 'matrix')
        .attr('values', '0.2 0 0 0 0 0 0.2 0 0 0 0 0 0.2 0 0 0 0 0 1 0');

filter.append('feGaussianBlur')
        .attr('result', 'blurOut')
        .attr('in', 'matrixOut')
        .attr('stdDeviation', 5);

filter.append('feBlend')
        .attr('in', 'SourceGraphic')
        .attr('in2', 'blurOut')
        .attr('normal');

The filter can then be added in the usual D3 was as an attribute to the pie’s g element.

d3.select('g').attr('filter', 'url(#dropshadow)');

And this leaves us with a decent interactive pie chart. Here is the complete source code:

<!DOCTYPE html>

<html>
<head>
    <meta charset="utf-8">
    <script src="d3.v3.js"></script>
    <style>
        .beer {
           fill: gold;
        }

        .wine {
            fill: darkred;
        }

        .selected {
            font-weight: bold;
            color: darkred;
        }

        svg {
            background-color: lightgray;
        }
    </style>
</head>
<body>
<table>
    <tr>
        <th>Drink</th>
        <th>Percentage</th>
    </tr>
    <tr class="beer">
        <td>Beer</td>
        <td>67</td>
    </tr>
    <tr class="wine">
        <td>Wine</td>
        <td>33</td>
    </tr>
</table>
<script>
    var data = [
        {name: 'beer', percentage: 67},
        {name: 'wine', percentage: 33}
    ];

    var pieLayout = d3.layout.pie();
    pieLayout.value(function(d) {
        return d.percentage;
    });

    var pie = pieLayout(data);
    console.log(pie);

    var pieRadius = 200;
    var arc = d3.svg.arc();
    arc.outerRadius(pieRadius);

    var yScaleFactor = 0.7;

    var svg = d3.select('body')
            .append('svg')
            .attr('width', 700)
            .attr('height', 500);

    var defs = svg.append('defs');

    var filter = defs.append('filter')
            .attr('id', 'dropshadow')
            .attr('x', '-10%')
            .attr('y', '-10%')
            .attr('width', '120%')
            .attr('height', '120%');

    filter.append('feOffset')
            .attr('result', 'offOut')
            .attr('in', 'SourceGraphic')
            .attr('dx', -3)
            .attr('dy', 7);

    filter.append('feColorMatrix')
            .attr('result', 'matrixOut')
            .attr('in', 'offOut')
            .attr('type', 'matrix')
            .attr('values', '0.2 0 0 0 0 0 0.2 0 0 0 0 0 0.2 0 0 0 0 0 1 0');

    filter.append('feGaussianBlur')
            .attr('result', 'blurOut')
            .attr('in', 'matrixOut')
            .attr('stdDeviation', 5);

    filter.append('feBlend')
            .attr('in', 'SourceGraphic')
            .attr('in2', 'blurOut')
            .attr('normal');

    svg.append('g')
            .attr('transform', 'translate(350, 250) scale(1, ' + yScaleFactor + ')')
            .attr('filter', 'url(#dropshadow)')
            .selectAll('path')
            .data(pie)
            .enter()
            .append('path')
            .attr('d', arc)
            .attr('class', function(d) { console.log(d); return d.data.name; })
            .attr('stroke', 'black')
            .on('mouseover', function(d) {
                        d3.select('tr.' + d.data.name)
                                .classed('selected', true);
                      })
            .on('mouseout', function(d) {
                        d3.select('tr.' + d.data.name)
                                .classed('selected', false);
                    });

    var textDistance = 25;

    var textX = function(startAngle, endAngle) {
        return (pieRadius + textDistance) * Math.sin(0.5 * (startAngle + endAngle));
    };

    var textY = function(startAngle, endAngle) {
        return -yScaleFactor * (pieRadius + textDistance) * Math.cos(0.5 * (startAngle + endAngle));
    };

    var textAnchor = function(startAngle, endAngle) {
        return Math.sin(0.5 * (startAngle + endAngle)) >= 0 ? 'start' : 'end';
    };

    var verticalTextShift = function(startAngle, endAngle) {
        return Math.cos(0.5 * (startAngle + endAngle)) >= 0 ? 0 : '1ex';
    };

    svg.append('g')
            .attr('transform', 'translate(350, 250)')
            .selectAll('text')
            .data(pie)
            .enter()
            .append('text')
            .attr('x', function(d) {
                      console.log(d.data.name + ': '); return textX(d.startAngle, d.endAngle);
                  })
            .attr('y', function(d) {
                      return textY(d.startAngle, d.endAngle);
                  })
            .attr('text-anchor', function(d) {
                          return textAnchor(d.startAngle, d.endAngle);
                      })
            .append('tspan')
            .attr('dy', function(d) {
                          return verticalTextShift(d.startAngle, d.endAngle);
                      })
            .text(function(d) {
                      return d.data.name;
                  });

</script>
</body>
</html>

Read more