Skip to content

Instantly share code, notes, and snippets.

@tomshanley
Last active September 13, 2019 11:09
Show Gist options
  • Save tomshanley/90ee19dd903b353c9ffbb81b348ad0f8 to your computer and use it in GitHub Desktop.
Save tomshanley/90ee19dd903b353c9ffbb81b348ad0f8 to your computer and use it in GitHub Desktop.
Recreating the NY Times immigration flows chart
license: mit
height: 1500
border: no
scrolling: no

Trying to recreate a reusable version of this type, without the D3 chord diagram business

https://www.nytimes.com/interactive/2018/06/20/business/economy/immigration-economic-impact.html

The labels are harder to programatically position, so I've opted for coordinates positioned on the link itself, inset from from end.

The chordNetwork function could be called with any set of nodes and links if you wish.

Built with blockbuilder.org

forked from tomshanley's block: Recreating the NY Times immigration flows chart, now with some extra links to make the overlaps look nicer

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")
paths.append("path")
.attr("d", function(d) {
return d.path;
})
.style("stroke-width", d => (strokeWidth(d.value) + 3))
.style("stroke", "white")
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