Demonstration of the creation of a nested pie chart.
The script source is heavily annotated to explain what the code is doing.
For StackOverflow question D3 label placement.
license: mit | |
border: yes | |
scrolling: yes | |
height: 600 |
Demonstration of the creation of a nested pie chart.
The script source is heavily annotated to explain what the code is doing.
For StackOverflow question D3 label placement.
<!DOCTYPE html> | |
<head> | |
<meta charset="utf-8"> | |
<script src="https://d3js.org/d3.v5.min.js"></script> | |
<style> | |
body { margin:0;top:0;right:0;bottom:0;left:0; } | |
text { | |
fill: black; | |
font-family: Helvetica, Arial, sans-serif; | |
font-size: 0.8em | |
} | |
.seg-border { | |
stroke: black; | |
stroke-width: 1px; | |
} | |
.seg-inner { | |
stroke: grey; | |
stroke-width: 1px; | |
stroke-dasharray: 5px; | |
} | |
.segment:hover { | |
opacity: 0.8 | |
} | |
input[type=range], .label--inline { | |
display: inline-block; | |
} | |
fieldset { | |
border: 0; | |
width: 15rem; | |
display: block; | |
float: left; | |
} | |
svg { | |
clear: left; | |
} | |
.label--inline { | |
width: 3rem; | |
text-align: center; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="chart"></div> | |
<script> | |
function chart(id) { | |
d3.csv('morley3.csv').then(raw_data => { | |
const data = raw_data.map( d => { return { Run: +d.Run, Experiment: +d.Expt, Speed: +d.Speed } } ) | |
let current = { | |
Run: 12, | |
Experiment: 6 | |
}, | |
groupBy = ['Run', 'Experiment', 'Speed'] | |
function addControls( grp, data ) { | |
const fs = d3.select(id) | |
.append('fieldset') | |
.classed('cntrl--' + grp, true) | |
fs.append('h4') | |
.text('Number of ' + grp.toLowerCase() + 's to display') | |
fs.append('label') | |
.text( d3.min( data, d => d[grp] ) ) | |
.classed('label--inline label--min', true) | |
fs.append('input') | |
.attr('id', 'input--' + grp) | |
.attr('type', 'range') | |
.attr('name', grp) | |
.attr('min', d3.min( data, d => d[grp] ) ) | |
.attr('max', d3.max( data, d => d[grp] ) ) | |
.attr('value', current[grp]) | |
.attr('step', 1) | |
.attr('list', 'tickmarks--' + grp) | |
.on('change', function() { | |
const el = this; | |
dataChange.call(el) | |
}) | |
fs.append('label') | |
.text( d3.max( data, d => d[grp] ) ) | |
.classed('label--inline label--max', true) | |
} | |
function dataChange() { | |
current[ this.name ] = this.value | |
draw( data.filter( d => d.Run <= current.Run && d.Experiment <= current.Experiment ) ) | |
} | |
function grouperSwitch() { | |
const fs = d3.select(id) | |
.append('fieldset') | |
.classed('cntrl--switch', true) | |
fs.append('h4') | |
.text('Switch grouping') | |
fs.append('p') | |
.attr('id', 'grouping_text') | |
.text(`Group by ${groupBy[0]} then ${groupBy[1]}`) | |
fs.append('button') | |
.text('Switch!') | |
.on('click', () => { | |
groupBy.splice(1,0,groupBy.shift()) | |
d3.select('#grouping_text') | |
.text(`Group by ${groupBy[0]} then ${groupBy[1]}`) | |
draw( data.filter( d => d.Run <= current.Run && d.Experiment <= current.Experiment ) ) | |
}) | |
} | |
addControls( groupBy[0], data ) | |
addControls( groupBy[1], data ) | |
grouperSwitch() | |
const width = 800, | |
height = 800, | |
radius = Math.min(height, width) * 0.5 - 100, | |
labelOffset = 10, | |
g = d3.select(id).append("svg") | |
.attr("width", width) | |
.attr("height", height) | |
.append("g") | |
.attr("transform", `translate(${width/2}, ${height/2})`), | |
arc = d3.arc() | |
.outerRadius(radius) | |
.innerRadius(0), | |
colourScheme = ['#fff', '#e6194b', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', '#46f0f0', '#f032e6', '#bcf60c', '#fabebe', '#008080', '#e6beff', '#9a6324', '#fffac8', '#800000', '#aaffc3', '#808000', '#ffd8b1', '#000075', '#808080'] | |
draw( data.filter( d => d.Run <= current.Run && d.Experiment <= current.Experiment ) ) | |
function draw(dataset) { | |
// scale covering the extent of the pie values | |
const segScale = d3.scaleLinear() | |
.domain( d3.extent( dataset, d => d[groupBy.slice(-1)[0]] ) ), | |
/* | |
Each datum is of the form | |
{ Run: <number>, Experiment: <number>, Speed: <number> } | |
The manner in which to group the data is set in the `groupBy` array, which | |
is initialised as ['Run', 'Experiment', 'Speed'], so data should be grouped | |
by run, then by experiment. | |
Using `groupBy[x]` enables us to switch the order of the grouping categories. | |
The last array item, `Speed`, is the value being measured by the pie chart (the | |
pie slice size is proportional to the speed); JavaScript doesn't have a handy | |
`array[-1]` for referring to the last item in an array, so we use | |
`array[groupBy.slice(-1)[0]]` | |
Nest the data by the first element of the `groupBy` array, Run. | |
resulting data structure: | |
[ { key: 1, values: [{ Run: 1, Experiment: 1, Speed: 958 }, { Run: 1, Experiment: 2, Speed: 869 } ... ], | |
{ key: 2, values: [{ Run: 2, Experiment: 1, Speed: 987 },{ Run: 2, Experiment: 2, Speed: 809 } ... ], | |
*/ | |
nested = d3.nest() | |
.key(d => d[groupBy[0]]) | |
.entries( dataset ), | |
// Run the pie generator on `nested` | |
// The pie generator does not alter the structure of nested; it just adds | |
// startAngle and endAngle values to each datum. | |
// The size of each slice is proportional to the sum of the values of the | |
// nested items. Slices are sorted by key (i.e. groupBy[0]) | |
pie = d3.pie() | |
.value(d => d3.sum(d.values, e => e[groupBy.slice(-1)[0]])) | |
.sort((a, b) => a.key - b.key) | |
(nested) | |
// run the pie generator on the children, with the speed value determining the | |
// segment size | |
// `d.data.values` is all the experiments in the run, or in pie terms, | |
// all the experiments in this piece of the pie. We're going to use | |
// `startAngle` and `endAngle` to specify that we're only generating | |
// part of the pie. The values for `startAngle` and `endAngle` come | |
// from using the pie chart generator on the run data. | |
// Pie segments are sorted by the second element of groupBy, i.e. by experiment. | |
pie.forEach( d => { | |
d.children = d3.pie() | |
.value(e => e[groupBy.slice(-1)[0]]) | |
.sort((a, b) => a[groupBy[1]] - b[groupBy[1]]) | |
.startAngle(d.startAngle) | |
.endAngle(d.endAngle) | |
(d.data.values) | |
}) | |
// clear the existing chart | |
g.selectAll('.slice').remove() | |
// bind the data to the DOM | |
const slices = g.selectAll('.slice') | |
.data(pie, d => d.key); | |
// add a `g` for each slice. | |
const sliceEnter = slices.enter() | |
.append("g") | |
.classed('slice', true) | |
// we want one label per slice (our top level grouping category), rather than | |
// having a label for every single datum, so append a label to each slice | |
sliceEnter.append('text') | |
.classed('label', true) | |
// | |
// if the midpoint of the segment is on the right of the pie, set the | |
// text anchor to be at the start. If it is on the left, set the text anchor | |
// to the end. | |
// | |
.attr('text-anchor', d => { | |
d.midPt = (0.5 * (d.startAngle + d.endAngle)) | |
return d.midPt < Math.PI ? 'start' : 'end' | |
}) | |
// | |
// to calculate the position of the label, I've taken the mid point of the | |
// start and end angles for the segment. I've then used d3.pointRadial to | |
// convert the angle (in radians) and the distance from the centre of | |
// the circle/pie (pie radius + labelOffset) into cartesian coordinates. | |
// d3.pointRadial returns [x, y] coordinates | |
// | |
.attr('x', d => d3.pointRadial(d.midPt, radius + labelOffset)[0]) | |
.attr('y', d => d3.pointRadial(d.midPt, radius + labelOffset)[1]) | |
// | |
// If the segment is in the upper half of the pie, move the text up a bit | |
// so that the label doesn't encroach on the pie itself | |
// | |
.attr('dy', d => { | |
let dy = 0.35; | |
if (d.midPt < 0.5 * Math.PI || d.midPt > 1.5 * Math.PI) { | |
dy -= 3.0; | |
} | |
return dy + 'em' | |
}) | |
.text(d => { | |
const ext = d3.extent(d.data.values.map(e => e[ groupBy[1] ])) | |
return `${groupBy[0]} ${d.data.key}, ${groupBy[1]}` | |
+ ( ext[0] === ext[1] ? ` ${ext[0]}` : 's ' + ext.join(' - ') ) | |
}) | |
.call(wrap, 50) | |
// now we can get on to generating the sub segments within each main segment. | |
// add another g for each experiment | |
// we already have the data bound to the DOM, but we want the d.children, | |
// which has the layout information for each segment | |
const segments = sliceEnter.selectAll('.segment') | |
.data(d => d.children) | |
.enter() | |
.append('g') | |
.classed('segment', true) | |
segments.append('path') | |
.classed('segment--path', true) | |
.attr('d', d => { | |
// set the arc radius to represent the relative value | |
return d3.arc() | |
.innerRadius(0) | |
.outerRadius( segScale(d.data[ groupBy.slice(-1)[0] ] ) * radius * 0.5 + 0.5*radius ) | |
(d) | |
}) | |
// | |
// the data was already numeric so it was easy to use the grouping element to | |
// as an array index to get colour data from my colour scheme. | |
// once we have the base colour, modify it to give a little visual | |
// differentiation to the segments | |
// | |
.attr('fill', (d,i) => { | |
const color = d3.rgb( colourScheme[ d.data[ groupBy[0] ] ] ) | |
return color.brighter( i / current[ groupBy[0] ] ) | |
}) | |
// add a title element that appears when mousing over the segment | |
.append('title') | |
.text(d => groupBy.map( e => e + ': ' + d.data[ e ] ).join(', ') ) | |
segments.append('line') | |
.attr('y2', radius) | |
// assign a class to each line so we can control the stroke, etc., using css | |
.attr('class', (d,i) => { | |
return 'segment--line slice-' + d.data[groupBy[0]] | |
+ ' seg-' + d.data[ groupBy[1] ] | |
+ ' seg-' + ( i === 0 ? 'border' : 'inner' ) | |
}) | |
// convert the angle from radians to degrees | |
.attr("transform", d => { | |
return "rotate(" + (180 + d.startAngle * 180 / Math.PI) + ")"; | |
}); | |
} | |
}) | |
function wrap(text, width) { | |
text.each(function() { | |
let text = d3.select(this), | |
words = text.text().split(/\s+/).reverse(), | |
word, | |
line = [], | |
lineNumber = 0, | |
lineHeight = 1.2, // ems | |
y = text.attr("y"), | |
x = text.attr("x"), | |
dy = parseFloat(text.attr("dy")), | |
tspan = text.text(null).append("tspan") | |
.attr("x", x) | |
.attr("y", y) | |
.attr("dy", dy + "em"); | |
while (word = words.pop()) { | |
line.push(word); | |
tspan.text(line.join(" ")); | |
if (tspan.node().getComputedTextLength() > width) { | |
line.pop(); | |
tspan.text(line.join(" ")); | |
line = [word]; | |
tspan = text.append("tspan") | |
.attr("x", x) | |
.attr("y", y) | |
.attr("dy", ++lineNumber * lineHeight + dy + "em") | |
.text(word); | |
} | |
} | |
}); | |
} | |
} | |
chart('#chart'); | |
</script> | |
</body> |
Expt | Run | Speed | |
---|---|---|---|
1 | 1 | 850 | |
1 | 2 | 740 | |
1 | 3 | 900 | |
1 | 4 | 1070 | |
1 | 5 | 930 | |
1 | 6 | 850 | |
1 | 7 | 950 | |
1 | 8 | 980 | |
1 | 9 | 980 | |
1 | 10 | 880 | |
1 | 11 | 1000 | |
1 | 12 | 980 | |
1 | 13 | 930 | |
1 | 14 | 650 | |
1 | 15 | 760 | |
1 | 16 | 810 | |
1 | 17 | 1000 | |
1 | 18 | 1000 | |
1 | 19 | 960 | |
1 | 20 | 960 | |
2 | 1 | 960 | |
2 | 2 | 940 | |
2 | 3 | 960 | |
2 | 4 | 940 | |
2 | 5 | 880 | |
2 | 6 | 800 | |
2 | 7 | 850 | |
2 | 8 | 880 | |
2 | 9 | 900 | |
2 | 10 | 840 | |
2 | 11 | 830 | |
2 | 12 | 790 | |
2 | 13 | 810 | |
2 | 14 | 880 | |
2 | 15 | 880 | |
2 | 16 | 830 | |
2 | 17 | 800 | |
2 | 18 | 790 | |
2 | 19 | 760 | |
2 | 20 | 800 | |
3 | 1 | 880 | |
3 | 2 | 880 | |
3 | 3 | 880 | |
3 | 4 | 860 | |
3 | 5 | 720 | |
3 | 6 | 720 | |
3 | 7 | 620 | |
3 | 8 | 860 | |
3 | 9 | 970 | |
3 | 10 | 950 | |
3 | 11 | 880 | |
3 | 12 | 910 | |
3 | 13 | 850 | |
3 | 14 | 870 | |
3 | 15 | 840 | |
3 | 16 | 840 | |
3 | 17 | 850 | |
3 | 18 | 840 | |
3 | 19 | 840 | |
3 | 20 | 840 | |
4 | 1 | 890 | |
4 | 2 | 810 | |
4 | 3 | 810 | |
4 | 4 | 820 | |
4 | 5 | 800 | |
4 | 6 | 770 | |
4 | 7 | 760 | |
4 | 8 | 740 | |
4 | 9 | 750 | |
4 | 10 | 760 | |
4 | 11 | 910 | |
4 | 12 | 920 | |
4 | 13 | 890 | |
4 | 14 | 860 | |
4 | 15 | 880 | |
4 | 16 | 720 | |
4 | 17 | 840 | |
4 | 18 | 850 | |
4 | 19 | 850 | |
4 | 20 | 780 | |
5 | 1 | 890 | |
5 | 2 | 840 | |
5 | 3 | 780 | |
5 | 4 | 810 | |
5 | 5 | 760 | |
5 | 6 | 810 | |
5 | 7 | 790 | |
5 | 8 | 810 | |
5 | 9 | 820 | |
5 | 10 | 850 | |
5 | 11 | 870 | |
5 | 12 | 870 | |
5 | 13 | 810 | |
5 | 14 | 740 | |
5 | 15 | 810 | |
5 | 16 | 940 | |
5 | 17 | 950 | |
5 | 18 | 800 | |
5 | 19 | 810 | |
5 | 20 | 870 | |
6 | 1 | 990 | |
6 | 2 | 940 | |
6 | 3 | 880 | |
6 | 4 | 910 | |
6 | 5 | 760 | |
6 | 6 | 910 | |
6 | 7 | 890 | |
6 | 8 | 910 | |
6 | 9 | 920 | |
6 | 10 | 950 | |
6 | 11 | 970 | |
6 | 12 | 970 | |
6 | 13 | 910 | |
6 | 14 | 840 | |
6 | 15 | 910 | |
6 | 16 | 1140 | |
6 | 17 | 1150 | |
6 | 18 | 900 | |
6 | 19 | 910 | |
6 | 20 | 970 |