A simple walk-through of preparing data to draw concentric arcs using d3.v4. Some of the d3.v4 features used:
There are many other arc examples too:
xxxxxxxxxx
<html>
<head>
<meta charset="utf-8">
<style>
.arc path {
stroke: #fff;
}
#arcsChart {
height: 500px;
width: 500px;
position: relative;
}
#arcsInfo {
position: absolute;
top:50%;
left:50%;
transform: translate(-50%, -50%);
color: #666;
font-size: 20px;
z-index: 2;
text-align: center;
}
</style>
<script src="//d3js.org/d3.v4.min.js"></script>
</head>
<body>
<div id="arcsChart">
<div id="arcsInfo"><span style="font-size: 12px">move mouse over coloured arcs!</span></div>
</div>
<script>
var numberWithCommas = function (d) {
if (d === undefined) return "undefined";
else return d.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};
var scoreColor = function (score) {
var color = 'silver';
switch (score) {
case "1.0":
color = "green";
break;
case "0.8":
color = "orange";
break;
case "0.6":
color = "gold";
break;
case "0.0":
color = "grey";
break;
default:
color = "silver";
}
return color;
};
d3.csv("./test-data.csv", function (error, data) {
if (error) throw error;
/* The first column, "score", looks like a number, but in this use-case it's treated as an
* ordinal (ordered categorical) and so it's not converted.
* The second column, "tests", is a pipe-separated string where different tests (known as
* 'Ha', 'Ch', 'Li', 'Ca', 'Pr' have binary pass/fail values.
* The third column, "count", is intended to be the count of samples with this combination
* of scores and tests results.
*
* First get the count to be a number. */
data.forEach(function(el, idx) {
data[idx].count = +data[idx].count;
});
/* Oddly, there are entries in this data with the same score and tests but with different
* count values. Normally one would fix that problem at the point where the test-data.csv
* is created. For this example, we'll re-combine/re-group/re-nest the data using d3.nest.*/
var nested = d3.nest()
.key(function(d) {return [d.score, d.tests]})
.entries(data);
/* The `nested` data is an array of objects. Each object has a "key" which is a string
* coercion of the `d.score` and `d.tests` from the `.key()` of the `d3.nest()` above.
* Each object also has a "values" array where each entry is the object from the original
* `data` - the parameter to the `.entries` of the `d3.nest()` above.
*
* What is wanted is the original `.score`, the original `.tests` and a new `.count` being
* the sum of the `.count` from the "values" array. */
var data2 = nested.map(function(el) {
// Instead of re-parsing the "key" (undoing the coercion into a string) it's easier to
// pick up the values from the zero-th element of the "values" - there will always be at
// least one entry, and - given the `nest().key()` function - always the same.
return {score: el.values[0].score,
tests: el.values[0].tests,
count: d3.sum(el.values, function(d) { return d.count; })};
});
/* The goal is to draw something which starts off like a pie chart - where each slice is one
* of the ordinal "score" values. Within each slice, concentric arcs are to represent the
* proportion of that score where the different "tests" were passed.
*
* A data structure like this seems to suit the problem:
* var arcsData = [ // Will be the list of slices
* {score: "score",
* total: <total-for-score>,
* tests: [ // Will be the list of arcs
* {test: "test",
* total: <total-for-test-within-this-score>},
* ...
* ]
* },
* ...
* ];
*
*/
var arcsData = [];
data2.forEach(function(el) {
// Is this score already in the slices?
var scoreIdx = arcsData
.map(function(slice) { return slice.score})
.indexOf(el.score);
if (scoreIdx === -1) {
// Not there? Add a zero value
scoreIdx = arcsData.push({score: el.score,
total: 0,
tests: []}) - 1;
}
// Update the slice/score total
arcsData[scoreIdx].total += el.count;
// Add the tests. We'll want the slices to have entries for every test - even those
// where none passed.
['Pr', 'Ca', 'Ha', 'Li', 'Ch'].forEach(function(test) {
var testIdx = arcsData[scoreIdx].tests
.map(function(t) {return t.test; })
.indexOf(test);
if (testIdx === -1) {
testIdx = arcsData[scoreIdx].tests.push({test: test,
total: 0}) - 1;
}
if (el.tests.indexOf(test + '1') != -1) {
// For this element `el` in the data2, did it pass the test `test`? In this
// case yes it did, so we can update the test's total.
arcsData[scoreIdx].tests[testIdx].total += el.count;
}
})
});
/* Start up some d3 */
var width = parseInt(getComputedStyle(
document.querySelector('#arcsChart')).getPropertyValue('width'));
var radius = width / 2;
var svg = d3.select("#arcsChart").append("svg")
.attr("width", width)
.attr("height", width)// it being circular in shape ..
.append("g")
.attr("transform", "translate(" + width / 2 + "," + width / 2 + ")");
var info = d3.select('#arcsInfo');
/* Expects to be given a slice of the data. It will give us the startAngle for each slice */
var slice = d3.pie()
.sort(null)
.value(function (d) {
return d.total;
});
var testInnerRadii = {
'Pr': radius - 100,
'Ca': radius - 80,
'Ha': radius - 60,
'Li': radius - 40,
'Ch': radius - 20
};
// Build our concentric arcs.
var concentricArcs = d3.merge(
// First run our data through `slice` (the `d3.pie()`) to get the startAngle and
// endAngle values
slice(arcsData).map(function (slice) {
// Each slice has a number of arcs (which is why we have to flatten them later
// using the `d3.merge`.
// A scale to give an endAngle for the arc which is ranged over the slice's
// angles. By this time, the `arcsData` has been through the `slice` function so
// the underlying data is within the `.data` object (see slice.data.total)
var sliceArcScale = d3.scaleLinear()
.range([slice.startAngle, slice.endAngle])
.domain([0, slice.data.total]);
// When the data for the `d3.arc()` generator has attributes for startAngle,
// endAngle, innerRadius, outerRadius you can use `d3.arc()` without needing to
// define accessors for them. These will also be reused for the background arcs.
return slice.data.tests.map(function (test) {
var innerRadius = testInnerRadii[test.test];
var endAngle = sliceArcScale(test.total);
return {
startAngle: slice.startAngle,
endAngle: endAngle,
padAngle: 0,
innerRadius: innerRadius,
outerRadius: innerRadius + 16,
arcName: test.test,
sliceName: slice.data.score,
sliceTotal: slice.data.total,
sliceEndAngle: slice.endAngle,
total: test.total,
fill: scoreColor(slice.data.score)};
})
})
);
// The difference for the background arcs is that they all share the slice's endAngle.
var concentricBackgrounds = concentricArcs.map(function(arc) {
var shallowCopy = Object.assign({}, arc);
shallowCopy.endAngle = shallowCopy.sliceEndAngle;
return shallowCopy;
});
// A rough-n-ready way of showing what arc is what.
function mouseOver(d) {
info.html('<p>score: ' + d.sliceName + '<br/>'
+ numberWithCommas(d.sliceTotal)
+ ' (' + (100. * d.sliceTotal / d3.sum(arcsData, function(s) {
return s.total;})).toFixed(2) + '%)'
+ '<br/>' + numberWithCommas(d.total)
+ ' (' + (100. * d.total / d.sliceTotal).toFixed(2)
+ '% of ' + d.sliceName + ')<br>test: ' + d.arcName + '</p>');
}
function mouseOut(d) { info.html(''); }
// This works as our concentricArcs and concentricBackgrounds (above) generated objects with
// the required attributes of startAngle, endAngle, innerRadius, outerRadius.
var arc = d3.arc();
// Build background arcs using the concentricBackgrounds data.
var background = svg.selectAll('.arc-backgrounds')
.data(concentricBackgrounds)
.enter()
.append("path")
.style("fill", "#f3f3f3") // pale grey
.on("mouseover", mouseOver)
.on("mouseout", mouseOut)
.attr("d", arc);
// Build the tests arcs using the conentricArcs data.
var testArcs = svg.selectAll(".arc")
.data(concentricArcs)
.enter().append("g")
.attr("class", "arc")
.on("mouseover", mouseOver)
.on("mouseout", mouseOut)
.append("path")
.attr("d", arc)
.style("fill", function (d) { return d.fill; });
});
</script>
</body>
</html>
https://d3js.org/d3.v4.min.js