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 |