|
<!DOCTYPE html> |
|
<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="https://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> |