Areas

Area charts

Area charts are like line charts, except that the area below the plotted line is filled in with color, usually to indicate volume. Area charts can also be stacked, representing cumulated totals. The totals are broken down into multiple categories, visualized by stacked areas of different colors.

In this chapter we will look at the D3 area generator and the D3 stack generator, which can be used to construct a stacked area chart. Stacked bar charts are also constructed using a stack generator (but not an area generator) in D3.

Area generator

d3.area() returns an area generator that produces an area defined by a topline and a baseline. Typically (but not necessarily), the topline and the baseline coordinates share the same x-values and differ in y-values (y0 and y1).


const area = d3.area()
  .x(d => scaleX(d.date))
  .y0(scaleY(0)) // baseline is the x-axis in the example below.			  
  .y1(d => scaleY(d.interest)); 

The next example shows an area chart of the bank interest on outstanding business loans in the Netherlands. Compare to a line chart.


const width = 640;
const height = 400;
const margin = {
  top: 35,
  right: 20,
  bottom: 35,
  left: 35,
}
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];

const svg = d3.select("svg")
  .attr("width", "100%")
  .attr("height", "100%")
  .attr("viewBox", `0 0 ${width} ${height}`)
  .attr("style", "width: 100%; height: auto;");

function drawChart(data) {

  const scaleX = d3.scaleUtc()
    .domain(d3.extent(data, d => d.date))
    .range([margin.left, width - margin.right])
    //.nice();

  const scaleY = d3.scaleLinear()
    .domain([0, d3.max(data, d => d.interest)])
    .range([height - margin.bottom, margin.top])
    .nice();			  

  // Declare the area generator.
  const area = d3.area()
    .x(d => scaleX(d.date))
    .y0(scaleY(0))			  
    .y1(d => scaleY(d.interest));				  

  // Append a path for the area.
  svg.append("path")
    .attr("fill", "tomato")
    .attr("stroke", "none")
    .attr("d", area(data));			  

  // Add the x-axis.
  svg.append("g")
    .attr("transform", `translate(0, ${height - margin.bottom})`)
    .call(d3.axisBottom(scaleX).ticks(null, "%Y").tickSizeOuter(0));

  // Add the y-axis.
  svg.append("g")
    .attr("transform", `translate(${margin.left},0)`)
    .call(d3.axisLeft(scaleY))
    .call(g => g.select(".domain").remove())
    .call(g => g.append("text")
      .attr("x", -margin.left)
      .attr("y", 10)
      .attr("fill", "currentColor")
      .attr("text-anchor", "start")
      .text("↑ Interest (%)"))
    .call(g => g.selectAll(".tick line").clone()
      .attr("x2", width - margin.left - margin.right)
      .attr("stroke-dasharray", "2,2")
      .attr("stroke-opacity", 0.3));

  // Add data pointer.				  
  svg.append("g")
    .selectAll("g")
    .data(data)
    .join("g")
      .attr("transform", d => `translate(${scaleX(d.date)}, 0)`)
      .attr("fill", "transparent")
      .attr("stroke", "transparent")				
    .call(g => g.append("rect")
      .attr("x", -0.5)				
      .attr("width", 1)  
      .attr("height", height)
      .attr("stroke-width", 6))					
    .call(g => g.append("circle")
      .attr("cx", 0)  
      .attr("cy", (d) => scaleY(d.interest))
      .attr("r", 4)
      .attr("stroke-width", 6))					
    .on("mouseover", function (e, d) {
      const group = d3.select(this)
      group.select("circle")
        .attr('fill', "currentColor")
        .attr('fill-opacity', 0.8)
        .attr('stroke', "currentColor")				  
        .attr('stroke-opacity', 0.4);
      group.select("rect")
        .attr('fill', "currentColor")
    })
    .on("mouseout", function (e, d) {
      const group = d3.select(this)
      group.select("circle")
        .attr('fill', "transparent")
        .attr('stroke', "transparent")
      group.select("rect")
        .attr('fill', "transparent")					
    })					
    .append("title")
      .text(d => `${months[d.date.getMonth()]} ${d.date.getFullYear()}: ${d.interest}%`);				  
};

// Fetch the CSV data file,
// and then call 'drawChart' to draw the chart:
(async function() {
  try {
    const response = await d3.csv('data/(23-11-13)_Bank_interest_on_outstanding_business_loans.csv', d => {
      d.date = new Date(d.date);
      d.interest = +d.interest;
      return d;
    })
    drawChart(response);	
  }
  catch (error) {
    document.querySelector("#errorMessage").textContent = error; 
  }		  
})();

Result:

Data source: De Nederlandsche Bank - Rente. Bank interest on outstanding business loans in the Netherlands (up to 23-11-13).

Download the used CSV data file.

Next an example with vertical baselines, both with curved lines:


const width = 400;
const height = 200;

const svg = d3.select("svg")
  .attr("width", "100%")
  .attr("height", "100%")
  .attr("viewBox", `0 0 ${width} ${height}`)
  .attr("style", "width: 100%; height: auto;");

const data = [
  {left: 20, right: 80},
  {left: 30, right: 70},
  {left: 20, right: 80},
  {left: 40, right: 60},
  {left: 35, right: 65},
  {left: 10, right: 90}
];

const scaleX = d3.scaleLinear()
  .domain([
    d3.min(data, d => d.left),
    d3.max(data, d => d.right)
  ])
  .range([0, width]);
const scaleY = d3.scaleLinear()
  .domain([0, data.length - 1])
  .range([height, 0]);		  

const areaGenerator = d3.area()
  .x0(d => scaleX(d.left))
  .x1(d => scaleX(d.right))		  
  .y((d, i) => scaleY(i))
  .curve(d3.curveCardinal);
const pathData = areaGenerator(data);

svg.append("path")
  .attr("fill", "DarkOliveGreen")
  .attr("stroke", "none")
  .attr("d", pathData);

Result:

As with the line generator you can handle "undefined" data using area.defined() and render to HTML <canvas> using area.context(context).

Radial areas

Next example is derived from the Observable notebook: "Radial area chart". It uses d3.areaRadial() to create a radial area generator.


const width = 400;
const height = width;
const margin = 10;	   
const outerRadius = width / 2 - margin;
const innerRadius = outerRadius - 40;

const svg = d3.select("svg")
  .attr("width", "100%")
  .attr("height", "100%")
  .attr("viewBox", `0 0 ${width} ${height}`)
  .attr("style", "width: 100%; height: auto;");
const container = svg.append("g")
  .attr("transform", `translate(${width/2}, ${height/2})`); // move origin to center

// Produce some random data:
const data = Array.from({ length: 200 }, (v, i) => {
  const nr = getRandomArbitrary(0, 40);
  return {
    min: nr,
    max: nr + getRandomArbitrary(40, 80),
  }
});	  

const scaleAngle = d3.scaleLinear()
  .domain([0, data.length - 1])		
  .range([0, 2 * Math.PI]);
const scaleRadius = d3.scaleLinear()
  .domain([
    d3.min(data, d => d.min),
    d3.max(data, d => d.max)
  ])
  .range([innerRadius, outerRadius]);			  

// Create the radial area:
const areaGenerator = d3.areaRadial()
  .angle((d, i) => scaleAngle(i))
  .innerRadius(d => scaleRadius(d.min))
  .outerRadius(d => scaleRadius(d.max));		
container.append("path")
  .attr("fill", "steelblue")
  .attr("fill-opacity", 0.2)
  .attr("stroke", "none")
  .attr("d", areaGenerator(data));

// Create a radial "average" line within the radial area:
const lineGenerator = d3.lineRadial()
  .angle((d, i) => scaleAngle(i))
  .radius(d => scaleRadius((d.min + d.max)/2));
container.append("path")
  .attr("fill", "none")
  .attr("stroke", "steelblue")
  .attr("stroke-width", 1)
  .attr("d", lineGenerator(data));

// Create radial axes:	
container.append("g")
  .selectAll()
  .data(scaleAngle.ticks()) // see 1)
  .join("g")
  .call(g => g.append("path")
    .attr("stroke", "currentColor")
    .attr("stroke-opacity", 0.2)
	// see 2):
    .attr("d", d => `
      M${d3.pointRadial(scaleAngle(d), 10)}
      L${d3.pointRadial(scaleAngle(d), outerRadius)}`)
  );
  .call(g => g.append("text")
    .attr("transform", d => `translate(${d3.pointRadial(scaleAngle(d), innerRadius - 10)})`)
    .attr("fill", "currentColor")
    .attr("stroke", "none")
    .attr("text-anchor", "middle")
    .attr("font-size", "10px")
    .text(d => d)
  );

container.append("g")
  .attr("text-anchor", "middle")
  .selectAll()
  .data(scaleRadius.ticks())
  .join("g")
  .call(g => g.append("circle")
    .attr("fill", "none")
    .attr("stroke", "currentColor")
    .attr("stroke-opacity", 0.1)
    .attr("r", d => scaleRadius(d))
  );

function getRandomArbitrary(min, max) {
  return Math.random() * (max - min) + min;
};	

Result:

1) scale.ticks() returns an array of values sampled from the scale’s domain. The ticks depend on the scale’s type and domain, but not its range.

2) d3.pointRadial(angle, radius) returns Cartesian coordinates [x, y] for the given angle in radians, and the given radius.

Stacks

The D3 stack generator (d3.stack()) can be used to create stacked bar charts and stacked area charts.

A stack generator does not produce a shape directly. Instead, it computes positions (for topline and baseline) which can be passed to an area generator or can be used to position bars in a bar chart.

The stack generator generates an empty array of positions if no keys are specified. stack.keys(keys) defines keys and for each key a series or layer is generated. A series or a layer is a category with a specific color and the different series or layers are stacked in the chart. The keys refer to the data properties to be stacked. In the example below the number of apples, bananas and cherries (the series identified by the keys) per month are stacked.

Each series contains a number of points, i.e. arrays with a lower and an upper value defining its baseline and topline. The points are an array with rows representing the series and columns representing all points with an equal value on the x-axis; the dates in the example below. Each point also has a data property that contains the data associated with the column to which it belongs. All this is shown in the example below.


const width = 640;
const height = 400;
const margin = {
  top: 35,
  right: 20,
  bottom: 35,
  left: 35,
}

const data = [
  {month: new Date("2015-01-01"), apples: 10, bananas: 20, cherries: 15},
  {month: new Date("2015-02-01"), apples: 15, bananas: 15, cherries: 15},
  {month: new Date("2015-03-01"), apples: 20, bananas: 25, cherries: 15}
];	

const svg = d3.select("svg")
  .attr("width", "100%")
  .attr("height", "100%")
  .attr("viewBox", `0 0 ${width} ${height}`)
  .attr("style", "width: 100%; height: auto;");		  

const seriesGen = d3.stack()
  .keys(["apples", "bananas", "cherries"]);

const series = seriesGen(data);
console.log(series); // logs an array of series.
/*
[ //  0) data ↓   ,    1) data ↓     ,    2) data ↓     ,
[[0,  10, data: …], [0,  15, data: …], [0,  20, data: …], key: "apples"],    // series "apples"
[[10, 30, data: …], [15, 30, data: …], [20, 45, data: …], key: "bananas"],   // series "bananas"
[[30, 45, data: …], [30, 45, data: …], [45, 60, data: …], key: "cherries"]   // series "cherries"
]

0) data = { apples: 10, bananas: 20, cherries: 15, month: Date Thu Jan 01 2015 }
1) data = { apples: 15, bananas: 15, cherries: 15, month: Date Sun Feb 01 2015 }
2) data = { apples: 20, bananas: 25, cherries: 15, month: Date Sun Mar 01 2015 } 
*/  

const scaleX = d3.scaleTime()
  .domain([data[0].month, data[2].month])
  .range([margin.left, width - margin.right]);

const scaleY = d3.scaleLinear()
  .domain([0, d3.max(series, d => d3.max(d, d => d[1]))])
  .range([height - margin.bottom, margin.top])
  .nice();

const color = d3.scaleOrdinal()
  .domain(series.map(d => d.key))
  .range(d3.schemeTableau10.slice(0, series.length))
  .unknown("#ccc");	

const area = d3.area()
  .x((d) => scaleX(d.data.month))
  .y0((d) => scaleY(d[0]))
  .y1((d) => scaleY(d[1]));

svg.append("g")
  .selectAll("path")
  .data(series)
  .join("path")
    .attr("d", area)
    .attr("fill", (d) => color(d.key));

Result:

The same date and stack generator to create a bar chart:


  … 

const scaleX = d3.scaleBand()
  .domain(data.map(d => d.month)) // array with all dates
  .range([margin.left, width - margin.right])
  .padding(0.1);

const scaleY = d3.scaleLinear()
  .domain([0, d3.max(series, d => d3.max(d, d => d[1]))])
  .range([height - margin.bottom, margin.top])
  .nice();

const color = d3.scaleOrdinal()
  .domain(series.map(d => d.key))
  .range(d3.schemeTableau10.slice(0, series.length))
  .unknown("#ccc");

svg.append("g")
  .selectAll("g")
  .data(series)
  .join("g")
    .attr("fill", d => color(d.key))
  .selectAll("rect")
  .data(d => d)
  .join("rect")
    .attr("x", d => scaleX(d.data.month))
    .attr("y", d => scaleY(d[1]))
    .attr("height", d => scaleY(d[0]) - scaleY(d[1]))
    .attr("width", scaleX.bandwidth());

Result:

stack.value()

Each series contains a number of points, arrays with a lower and an upper value defining their baseline and topline. Optional method stack.value() can be used to define the data property values associated with the point values in the series. By default, stack.value() tries to find the data object properties appointed by stack.keys() directly within the data. But if the data properties are in a nested object, we can specify an accessor to change the default behavior.


// The stack.value accessor defaults to:
const stack = d3.stack().value((d, key) => d[key]);

In the next example the data properties are in a nested object. stack.value() is needed to define them for the stack generator.


  …	

const data = [
  {month: new Date("2015-01-01"), fruitSales: {apples: 10, bananas: 20, cherries: 15}},
  {month: new Date("2015-02-01"), fruitSales: {apples: 15, bananas: 15, cherries: 15}},
  {month: new Date("2015-03-01"), fruitSales: {apples: 20, bananas: 25, cherries: 15}}
];	

  …		  

const seriesGen = d3.stack()
  .keys(["apples", "bananas", "cherries"])
  .value((obj, key) => obj.fruitSales[key]);

  …	

Result:

Next example handles a more complex scenario. Now there are multiple data points per column. Data needs to be grouped before the stack generator can be implemented. This is actually the elaborated example presented in the D3 documentation.


const width = 640;
const height = 400;
const margin = {
  top: 35,
  right: 20,
  bottom: 35,
  left: 35,
}

const data = [
  {date: new Date("2015-01-01"), fruit: "apples", sales: 3840},
  {date: new Date("2015-01-01"), fruit: "bananas", sales: 1920},
  {date: new Date("2015-01-01"), fruit: "cherries", sales: 960},
  {date: new Date("2015-01-01"), fruit: "durians", sales: 400},
  {date: new Date("2015-02-01"), fruit: "apples", sales: 1600},
  {date: new Date("2015-02-01"), fruit: "bananas", sales: 1440},
  {date: new Date("2015-02-01"), fruit: "cherries", sales: 960},
  {date: new Date("2015-02-01"), fruit: "durians", sales: 400},
  {date: new Date("2015-03-01"), fruit: "apples", sales: 640},
  {date: new Date("2015-03-01"), fruit: "bananas", sales: 960},
  {date: new Date("2015-03-01"), fruit: "cherries", sales: 640},
  {date: new Date("2015-03-01"), fruit: "durians", sales: 400},
  {date: new Date("2015-04-01"), fruit: "apples", sales: 320},
  {date: new Date("2015-04-01"), fruit: "bananas", sales: 480},
  {date: new Date("2015-04-01"), fruit: "cherries", sales: 640},
  {date: new Date("2015-04-01"), fruit: "durians", sales: 400}
];		

const svg = d3.select("svg")
  .attr("width", "100%")
  .attr("height", "100%")
  .attr("viewBox", `0 0 ${width} ${height}`)
  .attr("style", "width: 100%; height: auto;");			  

const seriesGen = d3.stack()
  .keys(d3.union(data.map(d => d.fruit))) // sets the keys: [apples, bananas, cherries, durians]
                                          // d3.union erases duplicate fruits
  .value(([, group], key) => group.get(key).sales); // see "Grouping data" below

const indexedData = d3.index(data, d => d.date, d => d.fruit); // see "Grouping data" below
const series = seriesGen(indexedData);

//console.log(series);
/*
[                                                        
  [[   0, 3840], [   0, 1600], [   0,  640], [   0,  320]], // series: key: apples
  [[3840, 5760], [1600, 3040], [ 640, 1600], [ 320,  800]], // series: key: bananas
  [[5760, 6720], [3040, 4000], [1600, 2240], [ 800, 1440]], // series: key: cherries
  [[6720, 7120], [4000, 4400], [2240, 2640], [1440, 1840]]  // series: key: durians
]
0) data = [ 
  Date Jan 01 2015,
  { 
    apples → Object { date: Date Jan 01 2015, fruit: "apples", sales: 3840 },
    bananas → Object { date: Date Jan 01 2015, fruit: "bananas", sales: 1920 },
    cherries → Object { date: Date Jan 01 2015, fruit: "cherries", sales: 960 }
    durians → Object { date: Date Jan 01 2015, fruit: "durians", sales: 400 }
}]
*/  

const scaleX = d3.scaleBand()
  //.domain(d3.union(data.map(d => d.date))); // or:
  .domain([...new Set(data.map(d => d.date))]) // erase duplicate dates.
  .range([margin.left, width - margin.right])
  .padding(0.1);

const scaleY = d3.scaleLinear()
  .domain([0, d3.max(series, d => d3.max(d, d => d[1]))])
  .range([height - margin.bottom, margin.top])
  .nice();

const color = d3.scaleOrdinal()
  .domain(series.map(d => d.key)) // array of all the fruits
  .range(["#608000", "#e6e600", "#D2042D", "#b38600"])
  .unknown("#ccc");

svg.append("g")
  .selectAll("g")
  .data(series)
  .join("g")
    .attr("fill", d => color(d.key))
  .call(g => g.append("title")
    .append("title")
    .text(d => `${d.key}`))
  .selectAll("rect")
  .data(d => d)
  .join("rect")
    .attr("x", d => scaleX(d.data[0])) // date
    .attr("y", d => scaleY(d[1]))
    .attr("height", d => scaleY(d[0]) - scaleY(d[1]))
    .attr("width", scaleX.bandwidth());

// Add an x-axis:
const timeAxis = d3.axisBottom(scaleX)
  .tickFormat(d3.timeFormat("%B %Y"))
  .tickSizeOuter(0);
svg.append("g")
  .attr("transform", `translate(0, ${height - margin.bottom})`)
  .call(timeAxis);  

// Add an y-axis:
d3.formatDefaultLocale({
  thousands: "\u{2009}", // thin space, SI standard thousands separator.
  grouping: [3],
});		
svg.append("g")
  .attr("transform", `translate(${margin.left}, 0)`)
  //.call(d3.axisLeft(scaleY).tickFormat(d3.format(""))); // no thousands separator
  .call(d3.axisLeft(scaleY).tickFormat(d3.format(","))); // use thousands separator		

Result:

PS. Why is the data in the example above not suitable for an area graph?

stack.order()

Only the bottom layer of a stacked chart is aligned, so comparing across categories is not always easy. It is important to chose the stack order carefully. The stack order of the series is the same as the order of how the keys are specified in the parameter array of stack.keys(). But the stack order can also be configured using stack.order(), for example, the smallest series at the bottom or the largest series in the middle.

The default order accessor of stack.order() is stackOrderNone, which uses the order given by the stack.keys() accessor. The order accessor can be a user defined function or one of the D3 built-in stack orders.


const stack = d3.stack().keys(["apples", "bananas", "cherries", "durians"]);
...equals:

const stack = d3.stack()
  .keys(["apples", "bananas", "cherries", "durians"])
  .order(d3.stackOrderNone);

The image below shows an area chart with default order and its clone, but with a stack order reversed from that given by the key accessor.

stack.offset()

By default, the baseline of an area graph is zero (generally this means the stacking starts at y = 0). The baseline and topline can also be configured using stack.offset().

The default offset accessor of stack.offset() is stackOffsetNone. The offset accessor can be a user defined function or one of the D3 built-in stack offsets.


const stack = d3.stack().keys(["apples", "bananas", "cherries", "durians"]);
...equals:

const stack = d3.stack()
  .keys(["apples", "bananas", "cherries", "durians"])
  .offset(d3.stackOffsetNone);

Next example shows a streamgraph. For a streamgraph it is recommended to use order d3.stackOrderInsideOut in conjunction with offset d3.stackOffsetWiggle, which minimizes the wiggle of layers.


const width = 640;
const height = 400;
const margin = {
  top: 35,
  right: 20,
  bottom: 60,
  left: 35,
}

const data = [
  {month: new Date("2015-01-01"), fruitSales: {apples: 10, bananas: 20, cherries: 15}},
  {month: new Date("2015-02-01"), fruitSales: {apples: 15, bananas: 15, cherries: 15}},
  {month: new Date("2015-03-01"), fruitSales: {apples: 20, bananas: 25, cherries: 15}},
  {month: new Date("2015-04-01"), fruitSales: {apples: 25, bananas: 10, cherries: 20}},
  {month: new Date("2015-05-01"), fruitSales: {apples: 15, bananas: 15, cherries: 10}}		  
];	

const svg = d3.select("svg")
  .attr("width", "100%")
  .attr("height", "100%")
  .attr("viewBox", `0 0 ${width} ${height}`)
  .attr("style", "width: 100%; height: auto;");		  

const seriesGen = d3.stack()
  .keys(["apples", "bananas", "cherries"])
  .value((obj, key) => obj.fruitSales[key])
  .order(d3.stackOrderInsideOut)
  .offset(d3.stackOffsetWiggle);			  

const series = seriesGen(data);
const lowestPoint = d3.min(series, d => d3.min(d, d => d[0]));

const scaleX = d3.scaleTime()
  .domain([data[0].month, data[data.length-1].month])
  .range([margin.left, width - margin.right])
  .nice();

const scaleY = d3.scaleLinear()
  .domain([lowestPoint, d3.max(series, d => d3.max(d, d => d[1]))])
  .range([height - margin.bottom, margin.top])
  .nice();

const color = d3.scaleOrdinal()
  .domain(series.map(d => d.key))
  .range(d3.schemeTableau10.slice(0, series.length))
  .unknown("#ccc");

const area = d3.area()
  .x((d) => scaleX(d.data.month))
  .y0((d) => scaleY(d[0]))
  .y1((d) => scaleY(d[1]))
  .curve(d3.curveCatmullRom.alpha(0.1));	  

svg.append("g")
  .selectAll("path")
  .data(series)
  .join("path")
  .attr("d", area)
  .attr("fill", (d) => color(d.key));

// Add an x-axis:
svg.append("g")
  .attr("transform", `translate(0, ${scaleY(lowestPoint)})`)
  .call(d3.axisBottom(scaleX)
  .tickFormat(d3.timeFormat("%b %Y"))
  .ticks(data.length)
  .tickSizeOuter(0)		  
);

// Add an y-axis:
svg.append("g")
  .attr("transform", `translate(${margin.left}, 0)`)
  .call(d3.axisLeft(scaleY)
  .tickFormat(d => Math.abs(d)) // remove minus sign
);

Result: