Editing Dynamic Bar Graphs in D3
Our Population Factfinder application includes a number of graphs and population pyramids that help users better visualize important American Community Survey (ACS) and census statistics. In order to make these nifty bar graphs, we use the popular data visualization library, D3.js. We build all of the bars, points, and lines that appear on our charts using Scalable Vector Graphics (SVG), which allow web developers to build, style, and organize a variety of different shapes. Because ACS and census statistics tend to have significant levels of uncertainty, we include margins of error in our bar graphs, which are the SVG grey rectangles shown below.
The margin of error (MOE) represents the range of possible values above and below our estimated statistic. A larger margin of error means more possible values for that variable, which demonstrates a higher level of uncertainty. What this means for our graphs is that the center of the MOE bar will intersect with our estimate bar, and extend symmetrically in both directions. Recently, demographers from the Population Division noticed a small bug on a graph representing newly-arrived residents in a Manhattan census tract. In this case, with a percentage of movers from another borough reaching 70%, and the margin of error reaching 55%, our graph was being stretched beyond 120%, something we definitely didn’t want.
My first instinct when solving this problem was to create a clipping path, a special SVG that only displays other graphics that are within its boundaries. The plan was to build a rectangle that covered only the part of the graph that reached to 100%, cutting off that extra 25%. I would append this rectangle to the moebars
, meaning it would only affect the visibility of that particular graphic. While this solution does get the job done, it produces a choppy image and feels more like covering up the problem than fixing it. So instead, I’ll demonstrate how we edited these dynamic D3 bar graphs using a series of fun formulas :).
Dynamic Functions for our SVG Shapes
Population Factfinder allows users to visualize data for numerous geographic areas and census variables. Each of these graphs will display different data and have different maximum values, which means they require a dynamic setup. When building these MOE rectangles with .append(‘rect’)
, we use a dynamic x coordinate, xFunctionMOE
, and a dynamic bar width, widthFunctionMOE
.
const moebars = svg.selectAll('.moebar')
.data(rawData, d => get(d, 'group'));moebars.enter()
.append('rect')
.attr('class', d => `moebar ${get(d, 'classValue')}`)
.attr('fill', (d) => {
if (get(d, 'color')) return get(d, 'color');
return '#2e6472';
})
.attr('opacity', 0.4)
.attr('x', xFunctionMOE)
.attr('y', d => y(get(d, 'group')) + (y.bandwidth() / 2) + -3)
.attr('height', 6)
.attr('width', widthFunctionMOE);
Adding a new condition to widthFunctionMOE
allows us to assure that large margins of error have limited widths. But before we jump into this, I’ll give you a little background on how these D3 graphs are built.
Margins
Margins represent the space between the outer boundary of our full chart shape and the start of the actual axes (including axes titles). The 0,0 mark starts at the top left corner of the chart. We define the margins early on in our code:
const margin = {
top: 10,
right: 10,
bottom: 60,
left: 10,
};
This way, we get to define the width of our graph, width
, and the width of our vertical axis titles, textWidth
using the margins.
const height = 800
const el = this.$();
const elWidth = el.width();
const height = this.get('height') - margin.top - margin.bottom;
const width = elWidth - margin.left - margin.right;
const textWidth = width * 0.35;
Scales
In D3, scales help us to easily translate raw data values, called the domain
, into new pixel values, called the range
, that situate our graphics correctly on the axes. In our case, we set the domain as 0 to our maximum x value, xMax
(0 to 1 displays as 0% to 100%). xMax
is equal to the estimate percentage plus the MOE percentage. In our buggy census tract, the maximum x value ended up being1.255
, which explains why it was extending the axes past 100%.
We set our range as textWidth
(~137px
) to width
(~392px
). So if we had a maximum x value of 0.5
, for example, our set of values in our domain: [0, 0.1, 0.2, 0.3, 0.4, 0.5]
would translate to this in our range: [137, 188, 239, 290, 341, 392]
. This way we can easily deal with our data in its original form and our linear scale will situate them on the graph automatically 🎉.
const xMax = max([
max(rawData, d => get(d, 'percent') + get(d, 'percent_m')),
]);const x = scaleLinear()
.domain([0, xMax])
.range([textWidth, width]);
Dynamic Maximums, Coordinates and Widths
The first step in fixing this bug was limiting the maximum x axis value to never go beyond 100%. We did this through a simple function called limitMax
that checked for when xMax
was greater than 1
, and if so, return 1
instead of the original xMax
.
function limitMax(newMax) {
if (newMax > 1) return 1;
return newMax;
}const xMax = max([
max(rawData, d => get(d, 'percent') + get(d, 'percent_m')),
]);const x = scaleLinear()
.domain([0, limitMax(xMax)])
.range([textWidth, width]);
Now our axis was capped at 100% but our MOE bar was still extending to the full 125%.
In order to start tackling the MOE bar, I had to consider how we were determining the x coordinate for this shape. This is where the domain and range come in handy. x(get(d, ‘percent')
grabs the percent
value from the original data and translates it into the pixel values that we need to organize shapes on our graph. x(0)
in our case would be 137
, which is equal to the textWidth
, and the start of our axes. After translating the percent
and percent_m
(moe percent) values into pixels, we then add textWidth
to get the MOE bar where it needs to be.
const xFunctionMOE = (d) => {
if (get(d, 'percent_m') > get(d, 'percent')) return x(0);
return x(get(d, 'percent')) - x(get(d, 'percent_m')) - -textWidth;
};
Now I had to consider how we were determining the width of the MOE bar. Margins of error exist symmetrically above and below the estimate percentage value (for this example, 0.55 below and above). We multiply this MOE percentage width by 2
to make sure it extends beyond the end of the estimate bar (look at figure above).
const widthFunctionMOE = (d) => {
const moeDefaultWidth = (x(get(d, 'percent_m')) - textWidth) * 2;
return moeDefaultWidth;
};
In order to limit the width of the MOE bar, I had to check for the condition where the barWidth + (moeDefaultWidth * 0.5)
were greater than the width of the x axis, axesWidth
. Remember that we already limited the maximum value of the axis to be 1
, so width
does not measure beyond 100%. I then measured the width of the “gap” between the estimate bar and the end of the axis as gapWidth
.
Instead of multiplying by 2
like we do for moeDefaultWidth
, I simply added the first half of the bar width, moeDefaultWidth * 0.5
, to the gapWidth
, meaning the MOE bar would only ever reach to 100% under this condition.
const widthFunctionMOE = (d) => {
const moeDefaultWidth = (x(get(d, 'percent_m')) - textWidth) * 2; const axesWidth = width - textWidth;
const barWidth = x(get(d, 'percent')) - textWidth;if (barWidth + (moeDefaultWidth * 0.5) > axesWidth) {
const gapWidth = axesWidth - barWidth;
const shortenedWidth = (moeDefaultWidth * 0.5) + gapWidth;
return shortenedWidth;
} return moeDefaultWidth;
};
And there you have it! No extra clipping path needed. Check out more of our D3 code here.