Skip to content

Instantly share code, notes, and snippets.

@chornbaker
Last active September 9, 2016 04:28
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save chornbaker/251c1f9aa12eed2cedcef8fc33b76df0 to your computer and use it in GitHub Desktop.
Save chornbaker/251c1f9aa12eed2cedcef8fc33b76df0 to your computer and use it in GitHub Desktop.
Chord Diagram with Multiple Categories
license: gpl-3.0
height: 600

Circular layout for visualizing relationships or network flows with multiple types or categories. This is a modification of d3-chord to enable another dimension of data to be shown between nodes. Hovering over nodes filters chords for that node. Hovering over a chord will select all chords with the same category.

Sample data shows migration flows between US regions by nativity from 2014-2015 (in thousands). Derived from 2015 US census data Geographic Mobility (Table 12)

(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3-array'), require('d3-path')) :
typeof define === 'function' && define.amd ? define(['exports', 'd3-array', 'd3-path'], factory) :
(factory((global.d3 = global.d3 || {}),global.d3,global.d3));
}(this, function (exports,d3Array,d3Path) { 'use strict';
var cos = Math.cos;
var sin = Math.sin;
var pi = Math.PI;
var halfPi = pi / 2;
var tau = pi * 2;
var max = Math.max;
function compareValue(compare) {
return function(a, b) {
return compare(
a.source.value + a.target.value,
b.source.value + b.target.value
);
};
}
function multichord() {
var padAngle = 0,
sortGroups = null,
sortSubgroups = null,
sortChords = null;
function multichord(matrix) {
var n = matrix.length,
nCategories = matrix[0][0].length,
groupSums = {},
groupIndex = d3Array.range(n),
subgroupIndex = [],
chords = [],
groups = chords.groups = new Array(n),
subgroups = chords.subgroups = new Array(n * n),
z,
k,
x,
x0,
dx,
i,
j;
// Compute the sum.
z = 0, i = -1; while (++i < n) {
if (!groupSums[i]){
groupSums[i] = {}
}
x = 0, j = -1; while (++j < n) {
if (!groupSums[j]){
groupSums[j] = {}
}
x += d3Array.sum(matrix[i][j])
if (!groupSums[i].in){
groupSums[i].in = d3Array.sum(matrix[i][j])
} else {
groupSums[i].in += d3Array.sum(matrix[i][j])
}
if (!groupSums[j].out){
groupSums[j].out = d3Array.sum(matrix[i][j])
} else {
groupSums[j].out += d3Array.sum(matrix[i][j])
}
}
subgroupIndex.push(d3Array.range(n));
z += x;
}
// Sort groups…
if (sortGroups) groupIndex.sort(function(a, b) {
return sortGroups(groupSums[a].in, groupSums[b].in);
});
// Sort subgroups…
if (sortSubgroups) subgroupIndex.forEach(function(d, i) {
d.sort(function(a, b) {
return sortSubgroups(d3Array.sum(matrix[i][a]), d3Array.sum(matrix[i][b]));
});
});
// Convert the sum to scaling factor for [0, 2pi].
// TODO Allow start and end angle to be specified?
// TODO Allow padding to be specified as percentage?
z = max(0, tau - padAngle * n) / z;
dx = z ? padAngle : tau / n;
// Compute the start and end angle for each group and subgroup.
// Note: Opera has a bug reordering object literal properties!
x = 0, i = -1; while (++i < n) {
x0 = x, j = -1; while (++j < n) {
var di = groupIndex[i],
dj = subgroupIndex[di][j],
v = d3Array.sum(matrix[di][dj]),
a0 = x;
x += v * z;
subgroups[dj * n + di] = new Array(nCategories), k = -1; while (++k < nCategories) {
v = matrix[di][dj][k];
var b0 = a0,
b1 = a0 += v * z;
subgroups[dj * n + di][k] = {
index: di,
subindex: dj,
startAngle: b0,
endAngle: b1,
value: v,
category: k,
};
};
}
groups[di] = {
index: di,
startAngle: x0,
endAngle: x,
value: {in: groupSums[di].in,
out: groupSums[di].out}
};
x += dx;
}
// Generate chords for each (non-empty) subgroup-subgroup link.
i = -1; while (++i < n) {
j = i - 1; while (++j < n) {
k = -1; while (++k < nCategories) {
var source = subgroups[j * n + i][k],
target = subgroups[i * n + j][k];
if (source.value || target.value) {
chords.push(source.value < target.value
? {source: target, target: source}
: {source: source, target: target});
}
}
}
}
return sortChords ? chords.sort(sortChords) : chords;
}
multichord.padAngle = function(_) {
return arguments.length ? (padAngle = max(0, _), multichord) : padAngle;
};
multichord.sortGroups = function(_) {
return arguments.length ? (sortGroups = _, multichord) : sortGroups;
};
multichord.sortSubgroups = function(_) {
return arguments.length ? (sortSubgroups = _, multichord) : sortSubgroups;
};
multichord.sortChords = function(_) {
return arguments.length ? (_ == null ? sortChords = null : (sortChords = compareValue(_))._ = _, multichord) : sortChords && sortChords._;
};
return multichord;
}
var slice = Array.prototype.slice;
function constant(x) {
return function() {
return x;
};
}
function defaultSource(d) {
return d.source;
}
function defaultTarget(d) {
return d.target;
}
function defaultRadius(d) {
return d.radius;
}
function defaultStartAngle(d) {
return d.startAngle;
}
function defaultEndAngle(d) {
return d.endAngle;
}
function ribbon() {
var source = defaultSource,
target = defaultTarget,
radius = defaultRadius,
startAngle = defaultStartAngle,
endAngle = defaultEndAngle,
context = null;
function ribbon() {
var buffer,
argv = slice.call(arguments),
s = source.apply(this, argv),
t = target.apply(this, argv),
sr = +radius.apply(this, (argv[0] = s, argv)),
sa0 = startAngle.apply(this, argv) - halfPi,
sa1 = endAngle.apply(this, argv) - halfPi,
sx0 = sr * cos(sa0),
sy0 = sr * sin(sa0),
tr = +radius.apply(this, (argv[0] = t, argv)),
ta0 = startAngle.apply(this, argv) - halfPi,
ta1 = endAngle.apply(this, argv) - halfPi;
if (!context) context = buffer = d3Path.path();
context.moveTo(sx0, sy0);
context.arc(0, 0, sr, sa0, sa1);
if (sa0 !== ta0 || sa1 !== ta1) { // TODO sr !== tr?
context.quadraticCurveTo(0, 0, tr * cos(ta0), tr * sin(ta0));
context.arc(0, 0, tr, ta0, ta1);
}
context.quadraticCurveTo(0, 0, sx0, sy0);
context.closePath();
if (buffer) return context = null, buffer + "" || null;
}
ribbon.radius = function(_) {
return arguments.length ? (radius = typeof _ === "function" ? _ : constant(+_), ribbon) : radius;
};
ribbon.startAngle = function(_) {
return arguments.length ? (startAngle = typeof _ === "function" ? _ : constant(+_), ribbon) : startAngle;
};
ribbon.endAngle = function(_) {
return arguments.length ? (endAngle = typeof _ === "function" ? _ : constant(+_), ribbon) : endAngle;
};
ribbon.source = function(_) {
return arguments.length ? (source = _, ribbon) : source;
};
ribbon.target = function(_) {
return arguments.length ? (target = _, ribbon) : target;
};
ribbon.context = function(_) {
return arguments.length ? ((context = _ == null ? null : _), ribbon) : context;
};
return ribbon;
}
exports.multichord = multichord;
exports.ribbon = ribbon;
Object.defineProperty(exports, '__esModule', { value: true });
}));
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>US migration between regions by nativity: d3-multichord example</title>
<meta name="author" content="Charles Hornbaker">
<!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
<!--[if lt IE 9]>
<script src="https://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
</head>
<body>
<script src="https://d3js.org/d3.v4.min.js" charset="utf-8"></script>
<script src="https://d3js.org/d3-format.v1.min.js"></script>
<script src="https://d3js.org/d3-queue.v3.min.js"></script>
<script src="https://d3js.org/d3-selection.v1.min.js"></script>
<script src="d3-multichord.js"></script>
<style>
#circle circle {
fill: none;
pointer-events: all;
}
.group path {
stroke: #000;
stroke-width: .25px;
fill-opacity: 0.9;
}
path.chord {
stroke: #000;
stroke-width: .25px;
fill-opacity: 0.9;
}
path.fade {
display: none;
}
</style>
<div id="vis"></div>
<script>
// Adapted from Mike Bostock's UberData Chord diagram example
// https://bost.ocks.org/mike/uberdata/
// Overall page margins
var HEIGHT = 600,
WIDTH = 960;
outerRadius = Math.min(WIDTH, HEIGHT) / 2 - 40
innerRadius = outerRadius - 30;
// Formatting functions
var formatPercent = d3.format(".1%");
var formatNumber = function (x){
if (Math.abs(x) >= 1e9) {
return d3.format(",.2f")(x / 1e9) + " Billion"
}
else if (Math.abs(x) >= 1e6) {
return d3.format(",.2f")(x / 1e6) + " Million"
}
else {
return d3.format(",.0f")(x)
}
}
// Chord chart elements
var arc = d3.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius);
var layout = d3.multichord()
.padAngle(.05)
.sortSubgroups(d3.descending)
.sortChords(d3.descending);
var path = d3.ribbon()
.radius(innerRadius);
var svg = d3.select("#vis").append("svg")
.attr("width", WIDTH)
.attr("height", HEIGHT)
// .attr("x", CHORD_VIS.X)
// .attr("y", CHORD_VIS.Y)
d3.queue()
.defer(d3.json, "migration_regions.json")
.await(ready);
function ready(error, data) {
if (error) throw error;
var nodes = data.nodes,
categories = data.categories;
var chords = layout(data.links)
// Compute the chord layout.
var g = svg.append("g")
.attr("id", "circle")
.attr("transform", "translate(" + (WIDTH / 2) + "," + (HEIGHT / 2) + ")")
.datum(chords);
g.append("circle")
.attr("r", outerRadius)
g.append("g").attr("id", "groups");
g.append("g").attr("id", "chords");
var group, groupPath, groupText, chord;
// Add a group per neighborhood.
group = g.select("#groups")
.selectAll("g")
.data(function(chords){ return chords.groups})
.enter().append("g")
.attr("class", "group")
.on("mouseover", mouseover)
.on("mouseout", mouseover_restore);
// Add the group arc.
groupPath = group.append("path")
.attr("id", function(d, i) { return "group" + i; })
.attr("d", arc)
.style("fill", function(d, i) { return nodes[i].color; });
// Add a text label.
groupText = group.append("text")
.attr("x", 6)
.attr("dy", 15)
.append("textPath")
.attr("xlink:href", function(d, i) { return "#group" + i; })
.text(function(d, i) { return nodes[i].name; })
.attr("opacity", function(d, i) {
// Hide labels that don't fit
if (groupPath._groups[0][i].getTotalLength() / 2 - 25 < this.getComputedTextLength()) {
return 0;
} else {
return 1;
};
})
// Add a mouseover title.
group.append("title").text(function(d, i) {
return nodes[i].name
+ "\n" + "In: " + formatNumber(chords.groups[i].value.in)
+ "\n" + "Out: " + formatNumber(chords.groups[i].value.out);
});
// Add the chords.
chord = g.select("#chords").selectAll("g")
.data(function(chords) { return chords;})
.enter().append("g")
.attr("class", "chord");
chord.append("path")
.attr("class", "chord")
.style("fill", function(d) { return nodes[d.source.index].color; })
.attr("d", path)
.on("mouseover", mouseover_types)
.on("mouseout", mouseover_restore);
// Add a mouseover title for each chord.
chord.append("title").text(function(d) {
return categories[d.source.category].name
+ "\n" + nodes[d.source.index].name
+ " → " + nodes[d.target.index].name
+ ": " + formatNumber(d.source.value)
+ "\n" + nodes[d.target.index].name
+ " → " + nodes[d.source.index].name
+ ": " + formatNumber(d.target.value);
});
function mouseover(d) {
g.select("#chords").selectAll("path")
.classed("fade", function(p) {
return p.source.index != d.index
&& p.target.index != d.index;
});
}
function mouseover_types(d) {
g.select("#chords").selectAll("path")
.classed("fade", function(p) {
return p.source.category != d.source.category
&& p.target.category != d.target.category;
});
}
function mouseover_restore(d) {
g.select("#chords").selectAll("path")
.classed("fade", function(p) {
return false;
});
}
}
</script>
{"nodes":[{"name":"Midwest","color":"#414487"},{"name":"Northeast","color":"#22a884"},{"name":"South","color":"#2a788e"},{"name":"West","color":"#7ad151"}]
,
"categories":[{"name":"Native"},{"name":"Naturalized U.S. citizen"},{"name":"Not a U.S. citizen"}]
,
"links":[[[0,0,0],[49,0,6],[236,1,18],[135,11,6]],[[37,1,9],[0,0,0],[230,9,19],[94,3,0]],[[295,6,24],[282,8,25],[0,0,0],[323,18,29]],[[181,5,16],[107,13,13],[195,6,26],[0,0,0]]]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment