Skip to content

Instantly share code, notes, and snippets.

@tomshanley
Last active September 13, 2019 11:08
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save tomshanley/f1e78ed21d6b9c9a423a829c4edc1e84 to your computer and use it in GitHub Desktop.
Save tomshanley/f1e78ed21d6b9c9a423a829c4edc1e84 to your computer and use it in GitHub Desktop.
Recreating the NY Times immigration flows chart
license: mit
height: 1500
border: no
scrolling: no
function appendArrow(d, i) {
let arrowHeadSize = strokeWidth(d.value) * 3;
let thisPath = d3.select(this).node();
let pathLength = thisPath.getTotalLength();
let point = thisPath.getPointAtLength(pathLength);
let previousPoint = thisPath.getPointAtLength(pathLength - 1);
let rotation = 0;
if (point.y == previousPoint.y) {
rotation = point.x < previousPoint.x ? 180 : 0;
} else if (point.x == previousPoint.x) {
rotation = point.y < previousPoint.y ? -90 : 90;
} else {
let adj = Math.abs(point.x - previousPoint.x);
let opp = Math.abs(point.y - previousPoint.y);
let angle = Math.atan(opp / adj) * (180 / Math.PI);
if (point.x < previousPoint.x) {
angle = angle + (90 - angle) * 2;
}
if (point.y < previousPoint.y) {
rotation = -angle;
} else {
rotation = angle;
}
}
let dString =
"M" +
point.x +
"," +
(point.y - arrowHeadSize / 2) +
" L" +
(point.x + arrowHeadSize / 2) +
"," +
point.y +
" L" +
point.x +
"," +
(point.y + arrowHeadSize / 2);
let rotationString =
"rotate(" + rotation + "," + point.x + "," + point.y + ")";
g.append("path")
.attr("d", dString)
.attr("class", "arrow-head")
.attr("transform", rotationString)
.style("stroke", colour(d.source))
.style("fill", colour(d.source));
}
function createChordData(_nodes, _links, _width, _height) {
let graph = { nodes: [], links: [] };
let n = _nodes.length;
let maxI = n - 1;
const rotate = 0.5
const chartRadius = (Math.min(_width, _height) / 2) - 20
const pointRadius = 20;
const selfLinkRadius = 55;
const selfLinkOffset = 30;
const clockwiseLinksOffset = 40;
const antiClockwiseLinksOffset = 20;
const angleDegrees = 360 / n;
const angleRadians = angleDegrees * (Math.PI / 180);
const nodeAngleDegrees = 25;
const nodeAngleRadians = nodeAngleDegrees * (Math.PI / 180);
const cLinkGap = nodeAngleDegrees / ((n - 3) * 2 - 1);
_nodes.forEach(function(d, i) {
let node = {};
node.id = d.id;
node.index = i
node.position = i + rotate
node.coord = d3.pointRadial(angleRadians * node.position, chartRadius);
node.labelCoord = d3.pointRadial(angleRadians * node.position, chartRadius + selfLinkOffset + selfLinkRadius );
node.x = node.coord[0];
node.y = node.coord[1];
node.labelX = node.labelCoord[0];
node.labelY = node.labelCoord[1];
graph.nodes.push(node);
});
_links.forEach(function(d) {
let link = {};
link.source = d.source;
link.sourceIndex = linkIndex(link.source)
link.sourcePosition = linkPosition(link.source)
link.target = d.target;
link.targetIndex = linkIndex(link.target)
link.targetPosition = linkPosition(link.target)
link.value = d.value;
if (isCentreLink(link.sourceIndex, link.targetIndex)) {
let aS = 0;
let sourceOffset = 0;
let tS = 0;
let targetOffset = 0;
if (link.targetPosition < link.sourcePosition) {
let i = (link.sourcePosition - link.targetPosition - 2) * 2;
let start = angleDegrees * link.sourcePosition - nodeAngleDegrees / 2;
sourceOffset = start + i * cLinkGap;
let end = angleDegrees * link.targetPosition + nodeAngleDegrees / 2;
targetOffset = end - i * cLinkGap;
link.inner = Math.abs(link.sourcePosition - link.targetPosition) > n / 2 ? false : true;
} else {
let i = (link.targetPosition - link.sourcePosition - 2) * 2 + 1;
let start = angleDegrees * link.sourcePosition + nodeAngleDegrees / 2;
sourceOffset = start - i * cLinkGap;
let end = angleDegrees * link.targetPosition - nodeAngleDegrees / 2;
targetOffset = end + i * cLinkGap;
link.inner = Math.abs(link.sourcePosition - link.targetPosition) > n / 2 ? true : false;
}
sourceOffset = sourceOffset < 0 ? sourceOffset + 360 : sourceOffset;
aS = sourceOffset * (Math.PI / 180);
link.sourceCoord = d3.pointRadial(aS, chartRadius);
targetOffset = targetOffset < 0 ? targetOffset + 360 : targetOffset;
tS = targetOffset * (Math.PI / 180);
link.targetCoord = d3.pointRadial(tS, chartRadius);
link.sourceX = link.sourceCoord[0];
link.sourceY = link.sourceCoord[1];
link.targetX = link.targetCoord[0];
link.targetY = link.targetCoord[1];
}
graph.links.push(link);
});
createPathData(graph.links);
return graph;
function createPathData(links) {
links.forEach(function(link) {
let x1 = 0
let y1 = 0
let x2 = 0
let y2 = 0
let path = ""
//self links
if (link.sourcePosition == link.targetPosition) {
link.type = "selfLink"
let i = link.sourcePosition + n / 2;
let offset = 1;
let centre = d3.pointRadial(
angleRadians * link.sourcePosition,
chartRadius + selfLinkRadius + selfLinkOffset
);
let start = d3.pointRadial(angleRadians * i - offset, selfLinkRadius);
let end = d3.pointRadial(angleRadians * i + offset, selfLinkRadius);
x1 = centre[0] + start[0];
y1 = centre[1] + start[1];
x2 = centre[0] + end[0];
y2 = centre[1] + end[1];
path =
"M " +
x1 +
" " +
y1 +
" A " +
selfLinkRadius +
" " +
selfLinkRadius +
" 0 1 0 " +
x2 +
" " +
y2;
}
//anti-clockwise outer links
else if (
link.sourceIndex - link.targetIndex === 1 ||
(link.sourceIndex === 0 && link.targetIndex === maxI)
) {
link.type = "ac-outer"
let r = chartRadius + antiClockwiseLinksOffset;
let offset = nodeAngleRadians / 2 + 0.05;
let start = d3.pointRadial(angleRadians * link.sourcePosition - offset, r);
let end = d3.pointRadial(angleRadians * link.targetPosition + offset, r);
x1 = start[0];
x2 = end[0];
y1 = start[1];
y2 = end[1];
let sweep =
link.targetPosition * angleDegrees < link.sourcePosition * angleDegrees ? "0" : "1";
sweep = link.targetIndex === maxI && link.sourceIndex == 0 ? "0" : sweep;
path =
"M" +
x1 +
"," +
y1 +
" " +
"A" +
chartRadius +
" " +
chartRadius +
" 0 0 " +
sweep +
" " +
x2 +
" " +
y2;
}
//clockwiseLinks
else if (
link.targetIndex - link.sourceIndex === 1 ||
(link.sourceIndex === maxI && link.targetIndex === 0)
) {
link.type = "c-outer"
let r = chartRadius + clockwiseLinksOffset;
let offset = nodeAngleRadians / 2 + 0.05;
let start = d3.pointRadial(angleRadians * link.sourcePosition + offset, r);
let end = d3.pointRadial(angleRadians * link.targetPosition - offset, r);
x1 = start[0];
x2 = end[0];
y1 = start[1];
y2 = end[1];
let sweep =
link.targetPosition * angleDegrees < link.sourcePosition * angleDegrees ? "0" : "1";
sweep = link.targetIndex === 0 && link.sourceIndex == maxI ? "1" : sweep;
path =
"M" +
x1 +
"," +
y1 +
" " +
"A" +
chartRadius +
" " +
chartRadius +
" 0 0 " +
sweep +
" " +
x2 +
" " +
y2;
} else {
x1 = link.sourceX;
y1 = link.sourceY;
x2 = link.targetX;
y2 = link.targetY;
if (isOpposite(n, link.sourcePosition, link.targetPosition)) {
link.type = "inner-opposite"
path = "M" + x1 + " " + y1 + " L" + x2 + " " + y2;
} else {
link.type = "inner-curve"
let distance = Math.abs(link.sourcePosition - link.targetPosition);
let mid = 0;
if (distance < n / 2) {
mid = Math.min(link.sourcePosition, link.targetPosition) + distance / 2;
} else {
distance =
n -
Math.max(link.sourcePosition, link.targetPosition) +
Math.min(link.sourcePosition, link.targetPosition);
mid = Math.max(link.sourcePosition, link.targetPosition) + distance / 2;
mid = mid == n ? 0 : mid;
}
let midAngleRadians = (mid * angleDegrees) * (Math.PI / 180);
let opp = Math.abs(x1 - x2);
let adj = Math.abs(y1 - y2);
let hyp = triangleHypotenuse(opp, adj);
let ratio = 1 - hyp / (chartRadius * 2);
let r = ratio * chartRadius;
r = link.inner ? r : r - (cLinkGap + 0);
let cCoords = d3.pointRadial(midAngleRadians, r);
let c = cCoords[0] + " " + cCoords[1];
path = "M" + x1 + " " + y1 + " Q " + c + " " + x2 + " " + y2;
}
}
link.x1 = x1;
link.x2 = x2;
link.y1 = y1;
link.y2 = y2;
link.path = path;
});
}
function isOpposite(n, source, target) {
if (n % 2 !== 0) {
return false;
} else {
return Math.abs(source - target) == n / 2 ? true : false;
}
}
function linkPosition(id) {
let p = 0
graph.nodes.forEach(function(node){
if (id == node.id){
p = node.position
}
})
return p
}
function linkIndex(id) {
let index = 0
graph.nodes.forEach(function(node){
if (id == node.id){
index = node.index
}
})
return index
}
function isCentreLink(source, target) {
if (Math.abs(source - target) == 1) {
return false;
} else if (source == maxI && target == 0) {
return false;
} else if (target == maxI && source == 0) {
return false;
} else if (target == source) {
return false;
} else {
return true;
}
}
}
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<script src="links.js"></script>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="chordNetwork.js"></script>
<script src="appendArrow.js"></script>
<script src="triangleEquations.js"></script>
<style>
body {
font-family: sans-serif
}
text {
text-anchor: middle
}
path {
fill: none;
stroke-linecap: square;
stroke-opacity: 1
}
</style>
</head>
<body>
<div class="header">
<p>A D3.js re-creation of the New York Times' chart from the June 20 2018 article <a href="https://www.nytimes.com/interactive/2018/06/20/business/economy/immigration-economic-impact.html https://www.nytimes.com/interactive/2018/06/20/business/economy/immigration-economic-impact.html">Migrants Are on the Rise Around the World, and Myths About Them Are Shaping Attitudes"</a>.</p>
<h1>Migration in 2017 in millions</h1>
</div>
<div id="chart"></div>
<div class="footer">
<p>Data source: United Nations Department of Economic and Social Affairs, Population Division, <a href="http://www.un.org/en/development/desa/population/migration/publications/migrationreport/docs/MigrationReport2017.pdf">Migration Report 2017</a>.</p>
</div>
<script>
console.clear();
let nodes = [
{"id": "Europe"},
{"id": "Asia"},
{"id": "Oceania"},
{"id": "Africa"},
{"id": "Latin America"},
{"id": "North America"},
];
let links = migration2017
//Generate dummy data
//let n = 6;
//var s = 0;
//var t = 0;
/*nodes = d3.range(n).map(function(d) {
return { id: d };
});
for (s = 0; s < n; s++) {
target = 0;
for (t = 0; t < n; t++) {
let link = {};
link.source = s;
link.target = t;
link.value = s == t ? Math.random() * 100 : Math.random() * 20;
links.push(link);
}
}*/
let colour = d3
.scaleOrdinal()
.domain(nodes.map(d => d.id)) //.range(['#1b9e77','#d95f02','#7570b3','#e7298a','#66a61e','#e6ab02'])
.range(["#eb978f"]);
const width = 600;
const height = width;
const m = 150;
const margin = { top: m, bottom: m, left: m, right: m };
let strokeWidth = d3.scaleLinear()
.domain(d3.extent(links, d => d.value))
.range([1, 20]);
let chartData = createChordData(nodes, links, width, height);
let svg = d3.select("#chart").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom);
let chart = svg.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
let g = chart
.append("g")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
let paths = g
.selectAll(".link")
.data(chartData.links)
.enter()
.append("g")
path = paths.append("path")
.attr("d", function(d) {
return d.path;
})
.style("stroke-width", d => strokeWidth(d.value))
.style("stroke", d => colour(d.source))
path.each(appendArrow)
path.each(appendLinkLabel)
let nodeLabels = g
.selectAll(".overlay")
.data(chartData.nodes)
.enter()
.append("g")
.append("text")
.text(d => d.id)
.style("fill", "black")
.attr("x", d => d.labelX)
.attr("y", d => (d.labelY + 6));
function roundValue(n) {
n = Math.round(n * 10)/10
return n
}
function appendLinkLabel(d) {
let thisPath = d3.select(this).node();
let pathLength = thisPath.getTotalLength();
let point = thisPath.getPointAtLength(pathLength - 20);
let parentG = d3.select(this.parentNode)
let label = parentG.append("g")
.attr("transform", "translate(" + point.x + "," + point.y + ")")
label.append("text")
.style("fill", "white")
.style("stroke", "white")
.style("stroke-width", "5px")
label.append("text")
.style("fill", colour(d.source))
.style("stroke", "none")
let text = d.value == 0 ? "" : roundValue(d.value)
label.selectAll("text")
.text(text)
.attr("dy", "0.35em")
}
</script>
</body>
//returns the length of the opposite side to the angle, using the adjacent side's length
function oppositeTan(angle, adjacent) {
return Math.tan(angle) * adjacent;
};
//returns the length of the adjacent side to the angle, using the hypotenuse's length
function adjacentCos(angle, hypotenuse) {
return Math.cos(angle) * hypotenuse;
}
//returns the length of the opposite side to the angle, using the adjacent's length
function oppositeTan(angle, adjacent) {
return Math.tan(angle) * adjacent;
}
//returns the length of the adjacent side to the angle, using the opposite's length
function adjacentTan(angle, opposite) {
return opposite / Math.tan(angle);
}
//returns the length of the opposite side to the angle, using the hypotenuse's length
function oppositeSin(angle, hypotenuse) {
return Math.sin(angle) * hypotenuse;
}
//returns the angle using the opposite and adjacent
function angleTan(opposite, adjacent) {
return Math.atan(opposite/adjacent);
}
//returns the length of the unknown side of a triangle, using the other two sides' lengths
function triangleSide(sideA, sideB) {
var hypothenuse, shorterSide;
if (sideA > sideB) {
hypothenuse = sideA;
shorterSide = sideB;
} else {
hypothenuse = sideB;
shorterSide = sideA;
};
return Math.sqrt(Math.pow(hypothenuse, 2) - Math.pow(shorterSide, 2))
};
//returns the length of the hypotenuse, using the other two sides' lengths
function triangleHypotenuse(sideA, sideB) {
return Math.sqrt(Math.pow(sideA, 2) + Math.pow(sideB, 2))
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment