Skip to content

Instantly share code, notes, and snippets.

@ialarmedalien
Last active October 18, 2018 19:58
Show Gist options
  • Save ialarmedalien/1e453ed9b148be442f50e06ad7eb3759 to your computer and use it in GitHub Desktop.
Save ialarmedalien/1e453ed9b148be442f50e06ad7eb3759 to your computer and use it in GitHub Desktop.
Nested pie chart
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment