Stacked Area Chart Let's walk through the steps of creating an area chart of the ethnicity of baseball players from 1947 - 2016 using D3 and Observable! Data and inspiration borrowed from Rob Crocker's Baseball Demographics Make Over Monday. First, let's create an svg DOM element, with width and height attributes that we'll define in a separate code block later. Remember, Observable's cells are "reactive", so you don't have to write everything from top to bottom in order for cells to know the value from another cell. If a value isn't defined yet, you'll see an error, but once you define it later the error will no longer persist.
svg = html`<svg width=${config.width} height=${config.height}></svg>`
Next, let's load our data using d3's csv loader. As the second argument we'll pass a formatter function that will convert the strings from our csv into numeric data types.
data = d3.csv("https://gist.githubusercontent.com/clhenrick/60b8de7fe07c0c6122cff4979feef1fe/raw/5f30c13b504b443bd4c027add21e79fbf1d8fa8d/pivot.csv", (d) => ({ year: +d.year, 'african american': +d['African American'], asian: +d.Asian, latino: +d['Latino'], white: +d['White'] }))
Whoops, we almost forgot to include the D3 library! No worries, we'll add it now and point to the latest version (5) so that we can use d3-fetch.
d3 = require("d3@next")
Set up some variables for chart dimensions and margins. Returning these values within an object allows us to reference them from other cells.
config = { const height = 600; const margin = { top: 10, bottom: 60, left: 65, right: 10 }; const innerHeight = height - margin.top - margin.bottom; const innerWidth = width - margin.left - margin.right; return { width, // note: `width` is a global constant that references the width of the notebook height, innerWidth, innerHeight, margin }; }
Create another block to store our x and y scales. Setting the domain for the x scale is simple, as we know it should be the extent of our data's years attribute. The y scale domain will be set using transformed data derived from d3.stack, which we'll get around to implementing next.
scales = { // reference some values from our config and format blocks const {innerWidth, innerHeight} = config; const {series} = format; // set up the x scale const xScale = d3.scaleLinear() .domain(d3.extent(data, d => d.year)) .range([0, innerWidth]); // set up the y scale // note: referencing the series property returned by format() even though it's defined below! const yScale = d3.scaleLinear() .domain([ d3.min(series, series => d3.min(series, d => d[0])), d3.max(series, series => d3.max(series, d => d[1])) ]) .range([innerHeight, 0]); // color scale const cScale = d3.scaleOrdinal(d3.schemePastel2); return { xScale, yScale, cScale }; }
Format our data using the d3.stack layout generator. The format block will return the transformed data in an object with the sole property called series.
format = { // create an array of strings for our data's ethnicity categories // we only want the column names for the ethnicity categories, not the "year" const keys = Object.keys(data[0]).slice(1); // use d3's stack layout to transform our data so that it plays nicely with our area path generator later const stack = d3.stack() .keys(keys) .order(d3.stackOrderDescending); // so that the largest grouping is stacked below the others const series = stack(data); return { series }; }
Now create y and x axis generators using d3-axis. We'll pass in our x and y scales and configure some other options such as the tick values.
axises = { // reference our x and y scales from earlier const {xScale, yScale} = scales // bottom axis generator const xAxis = d3.axisBottom() .scale(xScale) .ticks(10) .tickFormat(d3.format('')); // left axis generator const yAxis = d3.axisLeft() .scale(yScale) .tickValues([0, 0.25, 0.5, 0.75, 1]) .tickFormat(d3.format(',.0%')); return { xAxis, yAxis }; }
Now we can render our chart! Let's start by rendering the left and bottom axises
main = { const {margin, innerHeight, innerWidth} = config; const {xScale, yScale, cScale} = scales; const {xAxis, yAxis} = axises; const {series} = format; // main svg group const main = d3.select(svg).append('g') .attr('class', 'main') .attr('transform', `translate(${margin.left}, ${margin.top})`); // x axis main.append('g') .attr('class', 'axis x') .attr('transform', `translate(0, ${innerHeight})`) .call(xAxis); // x axis label d3.select('g.axis.x') .append('text') .attr('class', 'label') .attr('x', innerWidth / 2) .attr('y', 35) .text('Year') .style('text-anchor', 'middle'); // y axis main.append('g') .attr('class', 'axis y') .call(yAxis); // y axis label d3.select('.y.axis').append('text') .attr('class', 'label') .attr('x', -innerHeight / 2 + 25) .attr('y', -50) .attr('transform', `rotate(-90 0 0)`) .text('Ethnicity') .style('text-anchor', 'middle'); return main; }
The result of the above code will render our x and y axises, with labels for each axis.
Next, we'll render the svg area paths from our data using d3-shape's area() method
{ const {margin} = config; const {xScale, yScale, cScale} = scales; const {series} = format; // main svg group const main = d3.select(svg).append('g') .attr('class', 'main') .attr('transform', `translate(${margin.left}, ${margin.top})`); // define an area path generator const area = d3.area() .x(d => xScale(d.data.year)) .y0(d => yScale(d[0])) .y1(d => yScale(d[1])) // create the stacked area paths main.selectAll('.area') .data(series) .enter().append('path') .attr('class', 'area') .attr('fill', d => cScale(d.key)) .attr('d', area); }
Add a sprinkle of CSS for the axis labels
html`<style> text.label { fill: #333; font-size: 1.5em; } </style>`
Putting it all together, we finally have a stacked area chart
final = { const {margin, innerHeight, innerWidth} = config; const {xScale, yScale, cScale} = scales; const {xAxis, yAxis} = axises; const {series} = format; // main svg group const main = d3.select(svg).append('g') .attr('class', 'main') .attr('transform', `translate(${margin.left}, ${margin.top})`); // x axis main.append('g') .attr('class', 'axis x') .attr('transform', `translate(0, ${innerHeight})`) .call(xAxis); // x axis label d3.select('g.axis.x') .append('text') .attr('class', 'label') .attr('x', innerWidth / 2) .attr('y', 35) .text('Year') .style('text-anchor', 'middle'); // generate y axis main.append('g') .attr('class', 'axis y') .call(yAxis); // y axis label d3.select('.y.axis').append('text') .attr('class', 'label') .attr('x', -innerHeight / 2 + 25) .attr('y', -50) .attr('transform', `rotate(-90 0 0)`) .text('Ethnicity') .style('text-anchor', 'middle'); // define an area path generator const area = d3.area() .x(d => xScale(d.data.year)) .y0(d => yScale(d[0])) .y1(d => yScale(d[1])) // create the stacked area paths main.selectAll('.area') .data(series) .enter().append('path') .attr('class', 'area') .attr('fill', d => cScale(d.key)) .attr('d', area); return main }
TO DO: add some tooltips or labels to the area paths, or a legend, so we know what color corresponds to what ethnicity.