Split chord diagram with subchords. Handful, if you want to show relationships with categories filtering, e.g. by transport.
Last active
February 12, 2018 16:02
-
-
Save vkuchinov/212ba0d3b50350da8cfee2a90ff1d118 to your computer and use it in GitHub Desktop.
D3.JS Split Chords
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
border: no | |
height: 960 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[ | |
{ "name" : "Public Transport", "color": "#E00B27", "tag": "public" }, | |
{ "name" : "Car/Bicycle", "color" : "#2474A6", "tag": "car" }, | |
{ "name" : "Walking", "color" : "#F2A30F", "tag": "walking" } | |
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[ | |
{ | |
"name": "Barnet", | |
"children": [ | |
{ | |
"name": "YES", | |
"children": [ | |
{ | |
"name": "Public Transport", | |
"value": 3, | |
"color": 0 | |
}, | |
{ | |
"name": "Car/Bicycle", | |
"value": 1, | |
"color": 1 | |
}, | |
{ | |
"name": "Walking", | |
"value": 3, | |
"color": 2 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "NO", | |
"children": [ | |
{ | |
"name": "Walking", | |
"value": 3, | |
"color": 2 | |
} | |
], | |
"value": 0 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "Croydon", | |
"children": [ | |
{ | |
"name": "YES", | |
"children": [ | |
{ | |
"name": "Public Transport", | |
"value": 7, | |
"color": 0 | |
}, | |
{ | |
"name": "Car/Bicycle", | |
"value": 1, | |
"color": 1 | |
}, | |
{ | |
"name": "Walking", | |
"value": 1, | |
"color": 2 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "NO", | |
"children": [ | |
{ | |
"name": "Public Transport", | |
"value": 2, | |
"color": 0 | |
}, | |
{ | |
"name": "Car/Bicycle", | |
"value": 2, | |
"color": 1 | |
} | |
], | |
"value": 0 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "Hampton", | |
"children": [ | |
{ | |
"name": "YES", | |
"children": [ | |
{ | |
"name": "Public Transport", | |
"value": 4, | |
"color": 0 | |
}, | |
{ | |
"name": "Walking", | |
"value": 4, | |
"color": 2 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "NO", | |
"children": [ | |
{ | |
"name": "Public Transport", | |
"value": 2, | |
"color": 0 | |
}, | |
{ | |
"name": "Walking", | |
"value": 1, | |
"color": 2 | |
} | |
], | |
"value": 0 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "Ilford", | |
"children": [ | |
{ | |
"name": "YES", | |
"children": [ | |
{ | |
"name": "Car/Bicycle", | |
"value": 2, | |
"color": 1 | |
}, | |
{ | |
"name": "Walking", | |
"value": 6, | |
"color": 2 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "NO", | |
"children": [ | |
{ | |
"name": "Public Transport", | |
"value": 3, | |
"color": 0 | |
}, | |
{ | |
"name": "Car/Bicycle", | |
"value": 3, | |
"color": 1 | |
}, | |
{ | |
"name": "Walking", | |
"value": 3, | |
"color": 2 | |
} | |
], | |
"value": 0 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "Richmond", | |
"children": [ | |
{ | |
"name": "YES", | |
"children": [ | |
{ | |
"name": "Public Transport", | |
"value": 4, | |
"color": 0 | |
}, | |
{ | |
"name": "Car/Bicycle", | |
"value": 4, | |
"color": 1 | |
}, | |
{ | |
"name": "Walking", | |
"value": 1, | |
"color": 2 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "NO", | |
"children": [ | |
{ | |
"name": "Public Transport", | |
"value": 1, | |
"color": 0 | |
}, | |
{ | |
"name": "Car/Bicycle", | |
"value": 1, | |
"color": 1 | |
}, | |
{ | |
"name": "Walking", | |
"value": 1, | |
"color": 2 | |
} | |
], | |
"value": 0 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "Stanmore", | |
"children": [ | |
{ | |
"name": "YES", | |
"children": [ | |
{ | |
"name": "Car/Bicycle", | |
"value": 6, | |
"color": 1 | |
}, | |
{ | |
"name": "Walking", | |
"value": 1, | |
"color": 2 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "NO", | |
"children": [ | |
{ | |
"name": "Public Transport", | |
"value": 8, | |
"color": 0 | |
} | |
], | |
"value": 0 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "Sutton", | |
"children": [ | |
{ | |
"name": "YES", | |
"children": [ | |
{ | |
"name": "Car/Bicycle", | |
"value": 4, | |
"color": 1 | |
}, | |
{ | |
"name": "Walking", | |
"value": 4, | |
"color": 2 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "NO", | |
"children": [ | |
{ | |
"name": "Car/Bicycle", | |
"value": 1, | |
"color": 1 | |
}, | |
{ | |
"name": "Public Transport", | |
"value": 1, | |
"color": 0 | |
} | |
], | |
"value": 0 | |
} | |
], | |
"value": 0 | |
}, | |
{ | |
"name": "Uxbridge", | |
"children": [ | |
{ | |
"name": "NO", | |
"children": [ | |
{ | |
"name": "Public Transport", | |
"value": 2, | |
"color": 0 | |
}, | |
{ | |
"name": "Car/Bicycle", | |
"value": 2, | |
"color": 1 | |
}, | |
{ | |
"name": "Walking", | |
"value": 3, | |
"color": 2 | |
} | |
], | |
"value": 0 | |
} | |
], | |
"value": 0 | |
} | |
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<meta charset="utf-8"> | |
<style> | |
.ribbons { fill-opacity: 0.67; } | |
</style> | |
<svg width="960" height="960"></svg> | |
<script src="https://d3js.org/d3.v4.min.js"></script> | |
<script> | |
Array.prototype.insert = function(index_ ) { | |
index_ = Math.min(index_, this.length); | |
arguments.length > 1 | |
&& this.splice.apply(this, [index_, 0].concat([].pop.call(arguments))) | |
&& this.insert.apply(this, arguments); | |
return this; | |
}; | |
var theta = 0; | |
var categories = []; | |
var data = []; | |
d3.json("categories.json", function(error_, categories_) { | |
if (error_) throw error_; | |
categories_.forEach(function(d) { categories.push(d); }); | |
d3.json("data.json", function(error_, data_) { | |
if (error_) throw error_; | |
data_.forEach(function(d) { data.push(d); }); | |
data = parseData(data.reverse()); | |
inits(); | |
}); | |
}); | |
var svg = d3.select("svg"), | |
width = +svg.attr("width"), | |
height = +svg.attr("height"), | |
outerRadius = Math.min(width, height) * 0.35, | |
innerRadius = outerRadius * 0.95; | |
var formatValue = d3.formatPrefix(",.0", 1E3); | |
function inits(){ | |
var chord = d3.chord() | |
.padAngle(0.03) | |
.sortSubgroups(d3.descending); | |
var arc = d3.arc() | |
.innerRadius(innerRadius) | |
.outerRadius(outerRadius); | |
var ribbon = d3.ribbon() | |
.radius(innerRadius); | |
var extended = extendChords(chord(data.matrix), data); | |
var g = svg.append("g") | |
.attr("transform", "translate(" + width / 2 + "," + height / 2 + "),rotate(" + theta + ")") | |
.datum(extended); | |
var group = g.append("g") | |
.attr("class", "groups") | |
.selectAll("g") | |
.data(function(chords) { return chords.groups; }) | |
.enter().append("g"); | |
group.append("path") | |
.attr("class", function(d) { return d.tag; }) | |
.attr("d", arc) | |
.style("fill", function(d) { return d.color; }) | |
.style("stroke", "none") | |
.on("mouseover", function(d){ | |
var select = d3.select(this).attr("class"); | |
d3.selectAll("." + select).attr("opacity", 1.0); | |
}) | |
.on("mouseout", function(d){ | |
d3.selectAll(".ribbons").attr("opacity", 0.0); | |
}); | |
group.append("text") | |
.attr("dy", ".35em") | |
.style("font-family", "sans-serif") | |
.style("font-size", "10px") | |
.attr("text-anchor", function(d) { d.angle = (d.startAngle + d.endAngle) / 2; return ((d.angle > 0 && d.angle < 2.8) ? "end" : "start"); } ) | |
.attr("transform", function(d,i) { | |
d.angle = (d.startAngle + d.endAngle) / 2; | |
return "rotate(" + ((d.angle) * 180 / Math.PI) + ")" + "translate(0," + -1.0 * (outerRadius + 10) + ")" + | |
((d.angle > 0 && d.angle < 2.8) ? "rotate(90)" : "rotate(-90)"); | |
}) | |
.text(function(d, i) { return d.tag; }); | |
var split = splitChords(extended, data); | |
g.append("g") | |
.selectAll("path") | |
.data(split) | |
.enter().append("path") | |
.attr("class", function(d) { | |
var source = getTagByIndex(data, d.source.index); | |
var target = getTagByIndex(data, d.target.index); | |
return "ribbons " + source + " " + target + " " + getKeyByColor(d.color).toUpperCase(); | |
}) | |
.attr("d", ribbon) | |
.attr("opacity", 0) | |
.style("fill", function(d, i) { return d.color; }) | |
.style("stroke", "none"); | |
var legend = svg.append("g") | |
.attr("transform", "translate(" + (width / 2 - 64) + "," + (height * 0.75) + ")") | |
.selectAll("rect", "text") | |
.data(categories) | |
.enter() | |
.append("rect") | |
.attr("class", function(d){ return d.tag.toUpperCase(); }) | |
.attr("x", 0) | |
.attr("y", function(d, i){ return i * 32; }) | |
.attr("width", 24) | |
.attr("height", 24) | |
.style("fill", function(d) { return d.color; }) | |
.on("mouseover", function(d){ | |
var select = d3.select(this).attr("class"); | |
d3.selectAll("." + select).attr("opacity", 1.0); | |
}) | |
.on("mouseout", function(d){ | |
d3.selectAll(".ribbons").attr("opacity", 0.0); | |
}) | |
.select(function() { return this.parentNode; }) | |
.append("text") | |
.attr("dx", 40) | |
.attr("dy", function(d, i){ return i * 32 + 16; }) | |
.text(function(d, i){ return categories[i].name.toUpperCase(); }); | |
} | |
function parseData(data_){ | |
var output = { left: [], right: [], matrix: [], gaps: [] }; | |
for(var i = 0; i < data_.length; i++){ | |
var obj = { name: data_[i].name, targets: [], value: accumulateValues(data_[i]) } | |
for(var j = 0; j < data_[i].children.length; j++){ | |
if(!nodeExistsByName(output.right, data_[i].children[j].name)){ | |
var obj1 = { name : data_[i].children[j].name, value : data_[i].children[j].value, targets: [] } | |
obj1.targets.push( { name : data_[i].name, value: data_[i].children[j].value }); | |
output.right.push(obj1); | |
} else { | |
var obj1 = getNodeByName(output.right, data_[i].children[j].name); | |
obj1.targets.push( { index: getNodeIndexByName(output.right, data_[i].children[j].name), name : data_[i].name, value: data_[i].children[j].value }); | |
} | |
var group = parseChildren(data_[i].children[j].children); | |
var child = { index : getNodeIndexByName(output.right, data_[i].children[j].name), name: data_[i].children[j].name , value: data_[i].children[j].value , splits: group }; | |
obj.targets.push(child); | |
} | |
output.left.push(obj); | |
} | |
buildMatrix(output); | |
return output; | |
} | |
function buildMatrix(data_){ | |
var total = data_.left.reduce((prev_, next_) => prev_ + next_.value, 0), | |
emptyPerc = 0.5, | |
emptyStroke = Math.round(total * emptyPerc); | |
for(var i = 0; i < data_.left.length; i++){ | |
var row = Array.apply(null, Array(data_.left.length + 1)).map(Number.prototype.valueOf, 0); | |
row = row.concat(getNodesValues(data_.left[i].targets, data_.right)); | |
row.push(0); | |
data_.matrix.push(row); | |
} | |
data_.gaps.push(data_.matrix.length); | |
data_.matrix.push(Array.apply(null, Array(data_.left.length + data_.right.length + 2)).map(Number.prototype.valueOf, 0)); | |
data_.matrix[data_.matrix.length - 1][data_.left.length + data_.right.length + 1] = emptyStroke; | |
for(var i = 0; i < data_.right.length; i++){ | |
var row = getNodesValues(data_.right[i].targets, data_.left); | |
row.push(0); | |
row = row.concat(Array.apply(null, Array(data_.right.length + 1)).map(Number.prototype.valueOf, 0)); | |
data_.matrix.push(row); | |
} | |
data_.gaps.push(data_.matrix.length); | |
data_.matrix.push(Array.apply(null, Array(data_.left.length + data_.right.length + 2)).map(Number.prototype.valueOf, 0)); | |
data_.matrix[data_.matrix.length - 1][data_.left.length] = emptyStroke; | |
} | |
function extendChords(chords_, data_){ | |
var median = { min: Math.PI * 2, max: 0, value: 0 }; | |
for(var i = 0; i < chords_.groups.length; i++){ | |
if(i != data_.gaps[0] && i != data_.gaps[1]){ | |
if(chords_.groups[i].startAngle < median.min) { median.min = chords_.groups[i].startAngle; } | |
if(chords_.groups[i].endAngle > median.max) { median.max = chords_.groups[i].endAngle; } | |
if(i < data_.left.length) { chords_.groups[i].tag = data_.left[i].name.toUpperCase(); } | |
else{ chords_.groups[i].tag = data_.right[ i - data_.left.length - 1].name.toUpperCase(); } | |
chords_.groups[i].color = "#2474A6"; | |
}else{ | |
chords_.groups[i].tag = ""; | |
chords_.groups[i].color = "none"; | |
} | |
} | |
theta = median.min + (median.max - median.min) * 180 / Math.PI - 90 ; | |
return chords_; | |
} | |
function nodeExistsByName(array_, element_){ | |
for(var i = 0; i < array_.length; i++) { if (array_[i].name == element_) { return true; } } | |
return false; | |
} | |
function getNodeByName(array_, element_){ | |
for(var i = 0; i < array_.length; i++) { if (array_[i].name == element_) { return array_[i]; } } | |
return null; | |
} | |
function getNodeIndexByName(array_, element_){ | |
for(var i = 0; i < array_.length; i++) { if (array_[i].name == element_) { return i; } } | |
return null; | |
} | |
function getNodesValues(array_, targets_){ | |
var output = Array.apply(null, Array(targets_.length)).map(Number.prototype.valueOf, 0); | |
for(var i = 0; i < targets_.length; i++){ | |
for(var j = 0; j < array_.length; j++){ | |
if(targets_[i].name == array_[j].name) { output[i] += array_[j].value; } | |
} | |
} | |
return output; | |
} | |
function getTagByIndex(data_, index_){ | |
if(index_ != data.gaps[0] && index_ != data.gaps[1]){ | |
if(index_ < data_.left.length){ | |
return data_.left[index_].name.toUpperCase(); | |
}else{ | |
return data_.right[index_ - data_.left.length - 1].name.toUpperCase(); | |
} | |
}else{ | |
return "" | |
} | |
} | |
function getKeyByColor(color_){ | |
if(color_ == "none") { return ""; } | |
return categories.filter(function(item_) { return item_.color == color_; })[0].tag; | |
} | |
function accumulateValues(parent_){ | |
if (parent_.children != undefined) { | |
parent_.value = 0; | |
for (var i = 0; i < parent_.children.length; i++) { parent_.value += accumulateValues(parent_.children[i]); } | |
} | |
return parent_.value; | |
} | |
function parseChildren(children_){ | |
var output = []; | |
for(var i = 0; i < children_.length; i++){ | |
var obj = { value: children_[i].value, tag: children_[i].name, color: getColorByKey(children_[i].name) } | |
output.push(obj); | |
} | |
return output; | |
} | |
function getColorByKey(key_){ return categories.filter(function(item_) { return item_.name == key_ })[0].color; } | |
function splitChords(chords_, data_){ | |
for(var i = 0; i < data_.left.length; i++){ | |
for(var j = 0; j < data_.left[i].targets.length; j++){ | |
splitChord(chords_, i, data_.left.length + data_.left[i].targets[j].index + 1, data_.left[i].targets[j].splits ); | |
} | |
} | |
return chords_; | |
} | |
function splitChord(chords_, source_, target_, splits_ ){ | |
var chord = chords_.filter(function(item_) { return item_.source.index == source_ && item_.target.index == target_; })[0]; | |
var index = chords_.indexOf(chord); | |
const total = splits_.reduce((prev_, next_) => prev_ + next_.value, 0); | |
var sigma = { start: chord.source.startAngle, end: chord.source.endAngle, tau: (chord.source.endAngle - chord.source.startAngle) / total }; | |
var theta = { start: chord.target.startAngle, end: chord.target.endAngle, tau: (chord.target.endAngle - chord.target.startAngle) / total }; | |
parts = []; | |
var count = 0; | |
for(var k = 0; k < splits_.length; k++){ | |
var obj = JSON.parse(JSON.stringify(chord)); | |
obj.source.startAngle = sigma.start + count * sigma.tau; | |
obj.source.endAngle = obj.source.startAngle + splits_[k].value * sigma.tau; | |
obj.target.endAngle = theta.end - count * theta.tau; | |
obj.target.startAngle = obj.target.endAngle - splits_[k].value * theta.tau; | |
obj.source.index = chord.source.index; | |
obj.target.index = chord.target.index; | |
obj.source.subindex = chord.source.subindex; | |
obj.target.subindex = chord.target.subindex; | |
obj.source.value = splits_[k].value; | |
obj.target.value = splits_[k].value; | |
obj.color = splits_[k].color; | |
parts.push(obj); | |
count += splits_[k].value; | |
} | |
chords_.splice(index, 1); | |
chords_.insert(index, parts); | |
chords_[chords_.length - 1].color = "none"; | |
return chords_; | |
} | |
</script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment