Arcs

Pie and donut charts

In D3, pie and donut charts are created using an arc generator and a pie generator.

A pie generator computes the angles to represent the data. These angles can then be passed to an arc generator which produces a circular sector for a pie chart or an annular sector for a donut chart.

Arc generator

d3.arc() returns an arc generator that produces a circular sector centered at the origin (use a transform to move the arc to a different position).

Basically d3.arc() is an area generator, like d3.areaRadial().


const width = 200;
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 container = svg.append("g")
  .attr("transform", `translate(${width/2}, ${height/2})`); // move origin to center		  

// Declare the arc generator.
const arc = d3.arc()
  .startAngle(0)
  .endAngle(0.75 * Math.PI)
  .innerRadius(25)
  .outerRadius(100)
  .cornerRadius(10);		  

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

Result:

The next example uses an arc generator to combine multiple annular sectors into a donut chart. The joined data consist of explicitly defined start and end angles.


const width = 200;
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 container = svg.append("g")
  .attr("transform", `translate(${width/2}, ${height/2})`); // move origin to center

const anglesData = [
  {startAngle: 0, endAngle: 0.05 * 2 * Math.PI, label: "A"},
  {startAngle: 0.05 * 2 * Math.PI, endAngle: 0.12 * 2 * Math.PI, label: "B"},
  {startAngle: 0.12 * 2 * Math.PI, endAngle: 0.3 * 2 * Math.PI, label: "C"},
  {startAngle: 0.3 * 2 * Math.PI, endAngle: 0.6 * 2 * Math.PI, label: "D"},
  {startAngle: 0.6 * 2 * Math.PI, endAngle: 2 * Math.PI, label: "E"}
];

const colorScale = d3.scaleOrdinal(d3.schemeCategory10.slice(0, anglesData.length));		  

const arc = d3.arc()
  .startAngle(d => d.startAngle) // This line could have been omitted. See further below.
  .endAngle(d => d.endAngle) // This line could have been omitted. See further below.		
  .innerRadius(25)
  .outerRadius(100);		

container.append('g')
  .selectAll('g')
  .data(anglesData)
  .join('g')
    .call(g => g.append("path")
      .attr("fill", (d, i) => colorScale(i))
      .attr("stroke", "none")	
      .attr('d', arc))
    // add labels (A, B, C, D, E):	  
    .call(g => g.append("text")			  
      .attr('x', d => arc.centroid(d)[0])  // *)
      .attr('y', d => arc.centroid(d)[1])  // *)
      .attr("fill", "white")
      .attr("text-anchor", "middle")
      .attr('dy', '0.33em')
      .text(d => d.label));

Result:

*) arc.centroid() computes the center point of the arc for positioning labels.

Pie generator

In the example above the angles are explicitly defined in the data set. In an actual data visualization these angles typically represent meaningful data. A pie generator, d3.pie(), computes these angles, based on the data to be visualized. It produces a new data array, including the calculated angels.


const width = 200;
const height = width;

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

const data = [
  {value: 10, label: "A"},
  {value: 55, label: "C"},
  {value: 90, label: "D"},
  {value: 25, label: "B"},		  
  {value: 150, label: "E"}
];
const pie = d3.pie();
const arcData = pie(data.map(d => d.value)); // argument is an array of values

console.log(arcData); // logs:
/*
[
  {"data":  10, "value":  1, "index": 4, "startAngle": 6.092785752416568, "endAngle": 6.283185307179586, "padAngle": 0},		  
  {"data":  55, "value":  55, "index": 2, "startAngle": 4.569589314312426, "endAngle": 5.616786865509024, "padAngle": 0},		  
  {"data":  90, "value":  90, "index": 1, "startAngle": 2.8559933214452666, "endAngle": 4.569589314312426, "padAngle": 0},
  {"data":  25, "value":  25, "index": 3, "startAngle": 5.616786865509024, "endAngle": 6.092785752416568, "padAngle": 0},		  
  {"data":  150, "value": 150, "index": 0, "startAngle": 0, "endAngle": 2.8559933214452666, "padAngle": 0}
]		
*/

const colorScale = d3.scaleOrdinal(d3.schemeTableau10.slice(0, data.length));		  

const arc = d3.arc()
  .startAngle(d => d.startAngle)
  .endAngle(d => d.endAngle)		
  .innerRadius(width/4)
  .outerRadius(width/2 - 1);		

container.append('g')
  .selectAll('g')
  .data(arcData)
  .join('g')
  .call(g => g.append("path")
    .attr("fill", (d, i) => colorScale(i))
    .attr("stroke", "none")	
    .attr('d', arc))
  // add labels (A, B, C, D, E):  
  .each( function(d, i) {	 // arrow function does not work here: not an own this.
    const [x, y] = arc.centroid(d);
    d3.select(this).append("text")
      .attr('x', d => x)
      .attr('y', d => y)
      .attr("fill", "white")
      .attr("text-anchor", "middle")
      .attr('dy', '0.33em')
      .text(data[i].label)		
  });

Result:

Method each() can be used to invoke arbitrary code for each selected element.

Note in the example above that the sector start and end angles are calculated such that the sectors, by default, are ordered clockwise, from largest sector to smallest sector. However, the returned arc data array is in the same order as the data. The order is indicated by property index.

pie.value()

In the above example we mapped the data to d.value before invoking the pie generator.


const pie = d3.pie();
const arcData = pie(data.map(d => d.value));

This is similar to using a value accessor: pie.value(). The benefit of an accessor is that the input data remains associated with the returned objects. We can rewrite the example above with the next code:


  …
const pie = d3.pie().value((d) => d.value);
const arcData = pie(data);  
  …
container.append('g')
  …  
  .each( function(d, i) {
    const [x, y] = arc.centroid(d);
    d3.select(this).append("text")
      .attr('x', d => x)
      .attr('y', d => y)
      .attr("fill", "white")
      .attr("text-anchor", "middle")
      .attr('dy', '0.33em')
      .text(d => d.data.label) // !!	
  });

Sorting

The default sorting can be changed by using pie.sort() (the data comparator) or by using pie.sortValues() (the value comparator).

Function compare in pie.sort(compare) takes two arguments a and b, both representing elements from the data array. The sorting works similar to native JavaScript's array.sort().

The next example is the same as the previous one, except the sectors are now clockwise ordered from smallest sector to largest sector (ascending).


  …
  
const pie = d3.pie()
  .value((d) => d.value)
  .sort((a, b) => a.value - b.value);
const arcData = pie(data);

  …  

Result:

Sorting does not affect the order of the generated arc array that includes the calculated angels. That array is always in the same order as the input data array. It only affects the index properties and the computed angles of each arc.

D3 provides a number of methods that can be used as sorting functions. The above sorting could also have been achieved by:


  …
  
const pie = d3.pie()
  .value((d) => d.value)
  .sort((a, b) => d3.ascending(a.value, b.value));
const arcData = pie(data);

  …  

pie.sortValues() works similar to pie.sort(), except the two arguments a and b are values derived from the value accessor (pie.value()), rather than elements from the input data array. The next example does the same as the previous example.


  …
  
const pie = d3.pie()
  .value((d) => d.value)
  .sortValues(d3.ascending);
const arcData = pie(data);

  …  
Thus:

d3.pie().sort((a, b) => a.value - b.value); // is similar to:
d3.pie().sort((a, b) => d3.ascending(a.value, b.value)); // equals:
d3.pie().value((d) => d.value).sortValues(d3.ascending);

PS: d3.ascending is more accurate than (a, b) => a - b, for instance when data involves large numbers where subtracting values can introduce floating point errors. And d3.ascending works with any naturally orderable type, not just numbers. See this Observable notebook for more information.

Styling the pie

The pie generator provides methods pie.startAngle(), pie.endAngle() and pie.padAngle() and the arc generator provide method cornerRadius() (as shown previously) to style the pie or donut chart.

The first arc of the generated arc array (that includes the calculated angels) starts at the pie.startAngle() and the last arc ends at the pie.endAngle(). This allows the creation of semi-circular pie and donut charts. pie.padAngle() specifies the angular separation (padding) in radians between adjacent arcs.


const width = 300;
const height = width/2;

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})`); // move origin to center of bottom

const data = [
  {value: 20, label: "A"},
  {value: 55, label: "C"},
  {value: 90, label: "D"},
  {value: 35, label: "B"},		  
  {value: 150, label: "E"}
];
const pie = d3.pie()
  .value((d) => d.value)
  .startAngle(-0.5 * Math.PI)
  .endAngle(0.5 * Math.PI)
  .padAngle(0.02);
const arcData = pie(data);

const colorScale = d3.scaleOrdinal(d3.schemeTableau10.slice(0, data.length));		  

const arc = d3.arc()
  .startAngle(d => d.startAngle)
  .endAngle(d => d.endAngle)		
  .innerRadius(width/4)
  .outerRadius(width/2 - 1)
  .cornerRadius(5);		

container.append('g')
  .selectAll('g')
  .data(arcData)
  .join('g')
  .call(g => g.append("path")
    .attr("fill", (d, i) => colorScale(i))
    .attr("stroke", "none")	
    .attr('d', arc))
  // add labels (A, B, C, D, E):  
  .each( function(d, i) {
    const [x, y] = arc.centroid(d);
    d3.select(this).append("text")
      .attr('x', d => x)
      .attr('y', d => y)
      .attr("fill", "white")
      .attr("text-anchor", "middle")
      .attr('dy', '0.33em')
      .text(data[i].label)		
});

Result: