BookmarkSubscribeRSS Feed

How to create a donut chart in SAS Visual Analytics with #D3Thursday

Started ‎09-27-2018 by
Modified ‎03-25-2019 by
Views 3,670

In this #D3Thursday article, we'll move on from the basic bar chart example we started with to a (slightly) more exciting example: a donut chart!

 

 

Donut ChartDonut Chart

 

 This post’s topics include:

 

  • Creating arc segments with SVG paths
  • Designing a mobile-friendly legend
  • Creating advanced transitions

Arc Segments

The Path Generator

Unfortunately, there is no built-in SVG arc element. Instead, we can use the SVG path element to create the arc segments for our donut chart.

 

The shape of the SVG path element is determined by the d attribute. This attribute contains a string of letters and coordinates that detail the shape of the path. For more information on this attribute see the links at the bottom of this post.

 

Fortunately, D3.js has a built in function can do the hard job of specifying the d attribute for us.

// Create path generator
PATH = d3
.arc()
.outerRadius(RADIUS)
.innerRadius(0.7 * RADIUS);

// Create data arcs
DATA_ARCS = G_CHART_AREA.selectAll(".data-arc").data(DATA, function(d) {
return d.category;
});

DATA_ARCS.enter()
.append("path")
.classed("data-arc", true)
.classed("selectable", true)
.attr("fill", function(d, i) {
return COLOR_SCALE(i);
})
.on("click", function(d, i) {
selectElement(i, this);
})
.merge(DATA_ARCS)
.attr("d", function(d) {
return PATH({ startAngle: d.startAngle, endAngle: d.endAngle });
});

As you can see, the d3.arc() requires 4 pieces of information to create an arc segment: the outer radius, the inner radius, the start angle, and the end angle. In exchange for these four pieces of information, the d3.arc() function will return the value of the d attribute to create the arc segment.

 

Notice that we specify the radii when we create the path generator, but we specify the angles when assigning the d attribute. This is because the radii will remain constant for every path, but the start and end angles will change for each element depending on it’s data values. That begs the question though: how do we determine the angles?

 

Angle Calculations

We will calculate the start and end angles for each arc segment when we read in the data message.

// Restructure data from 2d array to array of objects
const total = d3.sum(VA_MESSAGE.data, function(d) {
return d[1];
});
let category, measure, startAngle, endAngle;
DATA = [];
for (let i = 0; i < VA_MESSAGE.data.length; i++) {
category = VA_MESSAGE.data[i][0];
measure = VA_MESSAGE.data[i][1];

currentData = d3.select("#" + category + "-data-arc");

startAngle = i == 0 ? 0 : DATA[i - 1].endAngle;
endAngle =
i == 0
? (measure / total) * 2 * Math.PI
: DATA[i - 1].endAngle + (measure / total) * 2 * Math.PI;

DATA.push({
category: category,
measure: measure,
startAngle: startAngle,
endAngle: endAngle
});
}

We start by using the d3.sum() function to calculate the sum of all measures. Then in our loop we can use the ratio of the current rows measure to the total to determine the start and end angles for each segment.

 

The Legend

Visual Analytics' Legend

To start, let’s look at how Visual Analytics (VA) handles the legend on it's donut chart.

 

VA LegendVA Legend

 As you can see, VA places each legend entry in its own equally spaced column. The length of these columns is determined by the entry with the longest text. This creates a clean aesthetic, but can lead to wasted space, especially if one legend entry is much longer than all the others. Additionally, VA will collapse the legend if the visualization becomes too narrow or too tall. This collapsed legend can be expanded into a tooltip by clicking on it as of VA 8.3.

 

VA Collapsed LegendVA Collapsed Legend

 For our donut chart, we will aim to create a more space effective legend that avoids collapsing regardless of visualization size.

 

Creating the Legend Elements

To create our legend, we have three different kinds of element: the legend title, the colored rectangles for each category, and the text for each category.

// Create legend title
LEGEND_TITLE = G_LEGEND.selectAll(".legend-title").data([DATA]);

LEGEND_TITLE.enter()
.append("text")
.classed("legend-title", true)
.text(METADATA.category)
.merge(LEGEND_TITLE)
.attr("transform", "translate(" + WIDTH / 2 + ", 0)");

// Create legend rects
LEGEND_RECTS = G_LEGEND.selectAll(".legend-rect").data(DATA, function(d) {
return d.category;
});

LEGEND_RECTS.enter()
.append("rect")
.classed("legend-rect", true)
.attr("width", LEG_RECT_WIDTH)
.attr("height", LEG_RECT_WIDTH)
.attr("fill", function(d, i) {
return COLOR_SCALE(i);
})
.merge(LEGEND_RECTS)
.attr("x", function(d, i) {
return LEG_EL_POS[i].x;
})
.attr("y", function(d, i) {
return LEG_EL_POS[i].y;
});

// Create legend text
LEGEND_TEXTS = G_LEGEND.selectAll(".legend-text").data(DATA, function(d) {
return d.category;
});

LEGEND_TEXTS.enter()
.append("text")
.classed("legend-text", true)
.text(function(d) {
return d.category;
})
.merge(LEGEND_TEXTS)
.attr("x", function(d, i) {
return LEG_EL_POS[i].x + LEG_RECT_WIDTH + LEG_RECT_PAD;
})
.attr("y", function(d, i) {
return LEG_EL_POS[i].y;
});

By now code like this should be starting to look like pretty standard fair. Still, there are a couple things worth mentioning.

 

First, notice that we are setting the fill of the legend rectangles using a function called COLOR_SCALE(). In addition to creating numerical scales for axes, D3.js can also create scales which map indices to colors as shown here.

 

More importantly, however, notice that we are using an array of objects to determine the x and y coordinates for the legend rectangles and text elements. Since we want to dynamically position these elements based on their length, the length of surrounding elements, the dimensions of the SVG, and other factors, we need to compute these positions elsewhere.

 

Positioning the Elements

We’ve created a helper function dedicated to the job of determining how to position all our legend entries.

function calculateLegendDimensions() {
// Create dummy text variable to get legend title height
let titleHeight;
SVG.append("text")
.classed("legend-text", true)
.text("TEST")
.each(function() {
titleHeight = this.getBBox().height;
this.remove();
});

// Create dummy text variables to get legend text height/widths
const textWidths = [];
let textHeight;
SVG.selectAll(".dummyText")
.data(DATA)
.enter()
.append("text")
.classed("legend-text", true)
.text(function(d) {
return d.category;
})
.each(function() {
textHeight = this.getBBox().height;
textWidths.push(this.getComputedTextLength());
this.remove();
});

// Determine which row each element will sit in and how long each row is
const rows = [];
const rowSums = [];
let rowSum;
let row = 0;
for (let i = 0; i < textWidths.length; i++) {
rowSum = textWidths[i] + LEG_RECT_WIDTH + LEG_RECT_PAD + LEG_TEXT_PAD;
rows.push(row);
while (
rowSum +
textWidths[i + 1] +
LEG_RECT_WIDTH +
LEG_RECT_PAD +
LEG_TEXT_PAD <=
WIDTH &&
i + 1 < textWidths.length
) {
i++;
rowSum += textWidths[i] + LEG_RECT_WIDTH + LEG_RECT_PAD + LEG_TEXT_PAD;
rows.push(row);
}
rowSums.push(rowSum);
row++;
}

// Calculate x and y coordinates for legend elements
LEG_EL_POS = [];
for (let i = 0; i < textWidths.length; i++) {
LEG_EL_POS.push({
x:
i == 0 || rows[i - 1] != rows[i]
? WIDTH / 2 - rowSums[rows[i]] / 2
: LEG_EL_POS[i - 1].x +
textWidths[i - 1] +
LEG_RECT_WIDTH +
LEG_RECT_PAD +
LEG_TEXT_PAD,
y: titleHeight + LEG_TITLE_PAD + rows[i] * (textHeight + LEG_ROW_PAD)
});
}
}

For simplicity, we’ve separated the job of determining the position for each legend entry into three steps.

  1. Determine the length of each legend text element by appending, measuring, and removing a dummy text element.
  2. Use these computed lengths to determine which row to place each element into and how long each row is.
  3. Use the determined row and row length to determine the x and y coordinates for each legend element.

Now we can use this function to position our legend elements properly.

// Compute the x y locations for legend elements
calculateLegendDimensions();

...

// Create legend elements

...

// Use legend height to determine radius and move legend
G_LEGEND.attr("transform", function() {
LEG_HEIGHT = this.getBBox().height;
RADIUS = Math.min(WIDTH, HEIGHT - LEG_HEIGHT - LEG_TOP_PAD) / 2;
return "translate(0, " + (HEIGHT - LEG_HEIGHT) + ")";
});

Notice that we are positioning the legend group and calculating the graph radius after the legend elements are created. The result of all of this is a donut chart with a compact legend that resizes and re-positions itself dynamically.

D3 LegendD3 Legend

Be aware that this new legend is not without its flaws.

  1. This legend is designed to aggressively take space from the chart, which can hamper the effectiveness of your chart.
  2. If legend a single legend entry is longer than the container, then it will be cutoff.
  3. If there are more legend entries than the container can hold, then it will be cutoff

D3 Cutoff LegendD3 Cutoff Legend

You could of course add logic to prevent these issues, but this legend isn’t meant to be a perfect replacement for VA’s. In fact, VA’s tooltip solution is much more elegant and robust, but we unfortunately cannot replicate that within an iFrame. This legend is meant to be a learning example and an acceptable substitute for our charts as we learn to create D3.js visualizations within VA.

 

Advanced Transitions

Now let’s look at how we can apply transitions to these arc segments to create seamless enter, exit, and update events. Unlike our simple transitions from last time, we don’t want to simple fade elements in and out as they are entered and exited. We want to have our segments “open” (enter) and “close” (exit) in place, without breaking the continuity of the donut chart.

 

Data Attributes

With only our data array with start and end angles, how can we know how to exit an element? By the definition of an exit, we no longer have the data for that element! How can we know how to exit an element without having any data for it?

 

One possible solution would be to store the previous data set as well as the updated data set so that you have access to both. This solution quickly becomes problematic though. What if we have four consecutive exit events? Would we store five data sets so that we have all the information we need?

 

A better solution is to store each elements data as a data attribute! This way we have access to an elements data so long as the element is still included in the DOM.

 

Let’s update our arc enter code to accomplish this.

DATA_ARCS.enter()
.append("path")
.classed("data-arc", true)
.classed("selectable", true)
.attr("id", function(d) {
return d.category + "-data-arc";
})
.attr("data-d", function(d) {
return JSON.stringify(d);
})
...

In addition to storing the data under the data-d attribute, we are also specifying the id attribute. This is so that we can select each element more easily when we want to update the data-d attribute.

 

Updating Angle Calculations

Our current method of calculating angles is also insufficient for the case of rapid consecutive updates to the data set. Suppose for instance that one element was exited, and then during the exit transition another element was entered that should go directly after the exiting element. With our current angle calculations, how would we know where the new element should be entered?

 

We don’t! We know where the entered element should wind up after the exiting element has been removed, but we need a way to reference the exiting elements current location!

const total = d3.sum(VA_MESSAGE.data, function(d) {
return d[1];
});
let category,
measure,
currentData,
finalStartAngle,
finalEndAngle,
startAngle,
endAngle;
DATA = [];
for (let i = 0; i < VA_MESSAGE.data.length; i++) {
category = VA_MESSAGE.data[i][0];
measure = VA_MESSAGE.data[i][1];

currentData = d3.select("#" + category + "-data-arc");

finalStartAngle = i == 0 ? 0 : DATA[i - 1].finalEndAngle;
finalEndAngle =
i == 0
? (measure / total) * 2 * Math.PI
: DATA[i - 1].finalEndAngle + (measure / total) * 2 * Math.PI;

startAngle = currentData.empty()
? finalStartAngle
: JSON.parse(currentData.attr("data-d")).startAngle;
endAngle = currentData.empty()
? finalEndAngle
: JSON.parse(currentData.attr("data-d")).endAngle;

DATA.push({
category: category,
measure: measure,
finalStartAngle: finalStartAngle,
finalEndAngle: finalEndAngle,
startAngle: startAngle,
endAngle: endAngle
});
}

In this updated code we now check for the data-d attribute on each element and store both the final angles for the arc as well as the current angles for the arc. Now we should be able to enter a new element directly after an exiting element even though it’s data is no longer in our DATA array!

 

Creating the Transition

Now that we have the information we need we can move on to creating our transitions.

function updateElements() {

...

// Update data arcs
DATA_ARCS = G_CHART_AREA.selectAll(".data-arc").data(DATA, function(d) {
return d.category;
});

DATA_ARCS.exit()
.attr("fill", "#000")
.classed("exiting", true)
.transition()
.duration(TRANS_TIME)
.attrTween("d", function(d, i) {
var prev = d3.select(this.previousElementSibling);
while (prev.node() && prev.classed("exiting")) {
prev = d3.select(prev.node().previousElementSibling);
}
var prevD = prev.node()
? JSON.parse(prev.attr("data-d"))
: { finalEndAngle: 0 };

return arcTween(
d,
d.startAngle,
prevD.finalEndAngle,
d.endAngle,
prevD.finalEndAngle
);
})
.remove();

...

}

...

// Tween function to create interpolators for arc segments
function arcTween(d, origStart, finalStart, origEnd, finalEnd) {
var interpolateStart = d3.interpolate(origStart, finalStart);
var interpolateEnd = d3.interpolate(origEnd, finalEnd);
return function(t) {
d.startAngle = interpolateStart(t);
d.finalStartAngle = finalStart;
d.endAngle = interpolateEnd(t);
d.finalEndAngle = finalEnd;
d3.select("#" + d.category + "-data-arc").attr("data-d", JSON.stringify(d));
return PATH(d);
};
}

For brevity, I have only included the exit transition code above. There are quite a few notable things about this transition.

 

First, in this transition we are changing the fill color of the data-arc to black. This is because our fill color is based on the index of the data-arc. To prevent duplicate colors, we can change the color of the exiting elements to black. We will look at a better solution to this problem in our next post.

 

Secondly, we are using the attrTween function rather than the standard attr function. This is because we don’t just want to specify the ending value of the d attribute. We want to specify every value of the d attribute as the transition progresses. To do this, we use the attrTween function to invoke the arcTween function which uses two custom interpolators to generate the intermediate values for the d attribute.

 

Finally, in order to determine what angle to use in the arcTween function shown above we must look at the previous sibling elements to the exiting element. We iterate through the previous sibling elements until we find an element that is not being exited or until we are out of previous siblings. Then we can use the end angle of the previous element as the location to exit our data-arc to.

 

As always, the complete code for this example as well as a live demo can be found using the GitHub links at the bottom of this post.

 

Additional Resources

Next Post

Next time we will expand on this example to make it look more like a standard VA object by adjusting VA’s style options, creating custom color gradients, and designing tooltips.

 D3Thursday Logo.png

 

Remember to follow the D3 Thursday article series on the SAS Communities Library and the #D3Thursday Twitter hashtag. Comment below or Tweet your questions and comments!

Version history
Last update:
‎03-25-2019 09:59 AM
Updated by:
Contributors

SAS Innovate 2025: Save the Date

 SAS Innovate 2025 is scheduled for May 6-9 in Orlando, FL. Sign up to be first to learn about the agenda and registration!

Save the date!

Free course: Data Literacy Essentials

Data Literacy is for all, even absolute beginners. Jump on board with this free e-learning  and boost your career prospects.

Get Started

Article Labels
Article Tags