Skip to content

Instantly share code, notes, and snippets.

@nbremer

nbremer/.block

Last active Jan 22, 2020
Embed
What would you like to do?
Stretched Chord Diagram - From educations to occupations
height: 780
license: bsd-2-clause

An example of using the Circular Flow diagram with actual data, as explained in my blog on How to create a Flow diagram with a circular Twist

It shows what types of educations (on the left) lead to what types of occupations (on the right) about 1.5 years after graduating. The data is based on about 18000 HBO graduates in 2014 in the Netherlands used for the State of the State project

////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////// Custom Chord Layout Function ////////////////
/////// Places the Chords in the visually best order ///////
///////////////// to reduce overlap ////////////////////////
////////////////////////////////////////////////////////////
///////// Slightly adjusted by Nadieh Bremer ///////////////
//////////////// VisualCinnamon.com ////////////////////////
////////////////////////////////////////////////////////////
//////// Original from the d3.layout.chord() function //////
///////////////// from the d3.js library ///////////////////
//////////////// Created by Mike Bostock ///////////////////
////////////////////////////////////////////////////////////
customChordLayout = function() {
var ε = 1e-6, ε2 = ε * ε, π = Math.PI, τ = 2 * π, τε = τ - ε, halfπ = π / 2, d3_radians = π / 180, d3_degrees = 180 / π;
var chord = {}, chords, groups, matrix, n, padding = 0, sortGroups, sortSubgroups, sortChords;
function relayout() {
var subgroups = {}, groupSums = [], groupIndex = d3.range(n), subgroupIndex = [], k, x, x0, i, j;
chords = [];
groups = [];
k = 0, i = -1;
while (++i < n) {
x = 0, j = -1;
while (++j < n) {
x += matrix[i][j];
}
groupSums.push(x);
subgroupIndex.push(d3.range(n).reverse());
k += x;
}
if (sortGroups) {
groupIndex.sort(function(a, b) {
return sortGroups(groupSums[a], groupSums[b]);
});
}
if (sortSubgroups) {
subgroupIndex.forEach(function(d, i) {
d.sort(function(a, b) {
return sortSubgroups(matrix[i][a], matrix[i][b]);
});
});
}
k = (τ - padding * n) / k;
x = 0, i = -1;
while (++i < n) {
x0 = x, j = -1;
while (++j < n) {
var di = groupIndex[i], dj = subgroupIndex[di][j], v = matrix[di][dj], a0 = x, a1 = x += v * k;
subgroups[di + "-" + dj] = {
index: di,
subindex: dj,
startAngle: a0,
endAngle: a1,
value: v
};
}
groups[di] = {
index: di,
startAngle: x0,
endAngle: x,
value: (x - x0) / k
};
x += padding;
}
i = -1;
while (++i < n) {
j = i - 1;
while (++j < n) {
var source = subgroups[i + "-" + j], target = subgroups[j + "-" + i];
if (source.value || target.value) {
chords.push(source.value < target.value ? {
source: target,
target: source
} : {
source: source,
target: target
});
}
}
}
if (sortChords) resort();
}
function resort() {
chords.sort(function(a, b) {
return sortChords((a.source.value + a.target.value) / 2, (b.source.value + b.target.value) / 2);
});
}
chord.matrix = function(x) {
if (!arguments.length) return matrix;
n = (matrix = x) && matrix.length;
chords = groups = null;
return chord;
};
chord.padding = function(x) {
if (!arguments.length) return padding;
padding = x;
chords = groups = null;
return chord;
};
chord.sortGroups = function(x) {
if (!arguments.length) return sortGroups;
sortGroups = x;
chords = groups = null;
return chord;
};
chord.sortSubgroups = function(x) {
if (!arguments.length) return sortSubgroups;
sortSubgroups = x;
chords = null;
return chord;
};
chord.sortChords = function(x) {
if (!arguments.length) return sortChords;
sortChords = x;
if (chords) resort();
return chord;
};
chord.chords = function() {
if (!chords) relayout();
return chords;
};
chord.groups = function() {
if (!groups) relayout();
return groups;
};
return chord;
};
////////////////////////////////////////////////////////////
/////////////// Custom Chord Function //////////////////////
//////// Pulls the chords pullOutSize pixels apart /////////
////////////////// along the x axis ////////////////////////
////////////////////////////////////////////////////////////
///////////// Created by Nadieh Bremer /////////////////////
//////////////// VisualCinnamon.com ////////////////////////
////////////////////////////////////////////////////////////
//// Adjusted from the original d3.svg.chord() function ////
///////////////// from the d3.js library ///////////////////
//////////////// Created by Mike Bostock ///////////////////
////////////////////////////////////////////////////////////
stretchedChord = function() {
var source = d3_source,
target = d3_target,
radius = d3_svg_chordRadius,
startAngle = d3_svg_arcStartAngle,
endAngle = d3_svg_arcEndAngle,
pullOutSize = 0;
var π = Math.PI,
halfπ = π / 2;
function subgroup(self, f, d, i) {
var subgroup = f.call(self, d, i),
r = radius.call(self, subgroup, i),
a0 = startAngle.call(self, subgroup, i) - halfπ,
a1 = endAngle.call(self, subgroup, i) - halfπ;
return {
r: r,
a0: [a0],
a1: [a1],
p0: [ r * Math.cos(a0), r * Math.sin(a0)],
p1: [ r * Math.cos(a1), r * Math.sin(a1)]
};
}
function arc(r, p, a) {
var sign = (p[0] >= 0 ? 1 : -1);
return "A" + r + "," + r + " 0 " + +(a > π) + ",1 " + (p[0] + sign*pullOutSize) + "," + p[1];
}
function curve(p1) {
var sign = (p1[0] >= 0 ? 1 : -1);
return "Q 0,0 " + (p1[0] + sign*pullOutSize) + "," + p1[1];
}
/*
M = moveto
M x,y
Q = quadratic Bézier curve
Q control-point-x,control-point-y end-point-x, end-point-y
A = elliptical Arc
A rx, ry x-axis-rotation large-arc-flag, sweep-flag end-point-x, end-point-y
Z = closepath
M251.5579641956022,87.98204731514328
A266.5,266.5 0 0,1 244.49937503334525,106.02973926358392
Q 0,0 -177.8355222451483,198.48621369706098
A266.5,266.5 0 0,1 -191.78901944612068,185.0384338992728
Q 0,0 251.5579641956022,87.98204731514328
Z
*/
function chord(d, i) {
var s = subgroup(this, source, d, i),
t = subgroup(this, target, d, i);
return "M" + (s.p0[0] + pullOutSize) + "," + s.p0[1] +
arc(s.r, s.p1, s.a1 - s.a0) +
curve(t.p0) +
arc(t.r, t.p1, t.a1 - t.a0) +
curve(s.p0) +
"Z";
}//chord
chord.radius = function(v) {
if (!arguments.length) return radius;
radius = d3_functor(v);
return chord;
};
chord.pullOutSize = function(v) {
if (!arguments.length) return pullOutSize;
pullOutSize = v;
return chord;
};
chord.source = function(v) {
if (!arguments.length) return source;
source = d3_functor(v);
return chord;
};
chord.target = function(v) {
if (!arguments.length) return target;
target = d3_functor(v);
return chord;
};
chord.startAngle = function(v) {
if (!arguments.length) return startAngle;
startAngle = d3_functor(v);
return chord;
};
chord.endAngle = function(v) {
if (!arguments.length) return endAngle;
endAngle = d3_functor(v);
return chord;
};
function d3_svg_chordRadius(d) {
return d.radius;
}
function d3_source(d) {
return d.source;
}
function d3_target(d) {
return d.target;
}
function d3_svg_arcStartAngle(d) {
return d.startAngle;
}
function d3_svg_arcEndAngle(d) {
return d.endAngle;
}
function d3_functor(v) {
return typeof v === "function" ? v : function() {
return v;
};
}
return chord;
}//stretchedChord
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<title>From your Education to your Job</title>
<!-- D3.js -->
<script src="http://d3js.org/d3.v3.js"></script>
<script src="d3.stretched.chord.js"></script>
<script src="d3.layout.chord.sort.js"></script>
<!-- jQuery -->
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<!-- Open Sans & CSS -->
<link href='http://fonts.googleapis.com/css?family=Open+Sans:700,400,300' rel='stylesheet' type='text/css'>
<style>
body {
font-family: 'Open Sans', sans-serif;
font-size: 12px;
font-weight: 400;
color: #525252;
text-align: center;
}
line {
stroke: #000;
stroke-width: 1.px;
}
text {
font-size: 8px;
}
path.chord {
fill-opacity: .80;
}
.title {
text-anchor: middle;
fill: #3B3B3B;
font-weight: 300;
font-size: 16px;
}
.titleLine {
stroke: #DCDCDC;
shape-rendering: crispEdges;
}
.title-h3 {
margin-top: 20px;
margin-bottom: 10px;
font-size: 24px;
font-weight: 500;
line-height: 1.1;
color: #3B3B3B;
}
@media (min-width: 500px) {
.explanation {
width: 50%;
margin: 0 auto;
}
}
</style>
</head>
<body>
<div class="title-h3">How much does your education define where you end up working?</div>
<div id="chart"></div>
<div style="width: 100%;">
<div class="explanation">
This chart shows how students from different educational sciences end up in their occupational sectors a year after graduation.
On the left we see the different educational tracks and on the right are the occupational sectors.
Thus there is a flow from left to right. The thickness of a line represents the number of students.
</div>
</div>
<script src="script.js"></script>
</body>
</html>
BSD 2-Clause License
Copyright (c) 2016-2018, Nadieh Bremer
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
////////////////////////////////////////////////////////////
//////////////////////// Set-up ////////////////////////////
////////////////////////////////////////////////////////////
var screenWidth = $(window).innerWidth(),
mobileScreen = (screenWidth > 500 ? false : true);
var margin = {left: 50, top: 10, right: 50, bottom: 10},
width = Math.min(screenWidth, 800) - margin.left - margin.right,
height = (mobileScreen ? 300 : Math.min(screenWidth, 800)*5/6) - margin.top - margin.bottom;
var svg = d3.select("#chart").append("svg")
.attr("width", (width + margin.left + margin.right))
.attr("height", (height + margin.top + margin.bottom));
var wrapper = svg.append("g").attr("class", "chordWrapper")
.attr("transform", "translate(" + (width / 2 + margin.left) + "," + (height / 2 + margin.top) + ")");;
var outerRadius = Math.min(width, height) / 2 - (mobileScreen ? 80 : 100),
innerRadius = outerRadius * 0.95,
opacityDefault = 0.7, //default opacity of chords
opacityLow = 0.02; //hover opacity of those chords not hovered over
//How many pixels should the two halves be pulled apart
var pullOutSize = (mobileScreen? 20 : 50)
//////////////////////////////////////////////////////
//////////////////// Titles on top ///////////////////
//////////////////////////////////////////////////////
var titleWrapper = svg.append("g").attr("class", "chordTitleWrapper"),
titleOffset = mobileScreen ? 15 : 40,
titleSeparate = mobileScreen ? 30 : 0;
//Title top left
titleWrapper.append("text")
.attr("class","title left")
.style("font-size", mobileScreen ? "12px" : "16px" )
.attr("x", (width/2 + margin.left - outerRadius - titleSeparate))
.attr("y", titleOffset)
.text("Education");
titleWrapper.append("line")
.attr("class","titleLine left")
.attr("x1", (width/2 + margin.left - outerRadius - titleSeparate)*0.6)
.attr("x2", (width/2 + margin.left - outerRadius - titleSeparate)*1.4)
.attr("y1", titleOffset+8)
.attr("y2", titleOffset+8);
//Title top right
titleWrapper.append("text")
.attr("class","title right")
.style("font-size", mobileScreen ? "12px" : "16px" )
.attr("x", (width/2 + margin.left + outerRadius + titleSeparate))
.attr("y", titleOffset)
.text("Occupation");
titleWrapper.append("line")
.attr("class","titleLine right")
.attr("x1", (width/2 + margin.left - outerRadius - titleSeparate)*0.6 + 2*(outerRadius + titleSeparate))
.attr("x2", (width/2 + margin.left - outerRadius - titleSeparate)*1.4 + 2*(outerRadius + titleSeparate))
.attr("y1", titleOffset+8)
.attr("y2", titleOffset+8);
////////////////////////////////////////////////////////////
/////////////////// Animated gradient //////////////////////
////////////////////////////////////////////////////////////
var defs = wrapper.append("defs");
var linearGradient = defs.append("linearGradient")
.attr("id","animatedGradient")
.attr("x1","0%")
.attr("y1","0%")
.attr("x2","100%")
.attr("y2","0")
.attr("spreadMethod", "reflect");
linearGradient.append("animate")
.attr("attributeName","x1")
.attr("values","0%;100%")
// .attr("from","0%")
// .attr("to","100%")
.attr("dur","7s")
.attr("repeatCount","indefinite");
linearGradient.append("animate")
.attr("attributeName","x2")
.attr("values","100%;200%")
// .attr("from","100%")
// .attr("to","200%")
.attr("dur","7s")
.attr("repeatCount","indefinite");
linearGradient.append("stop")
.attr("offset","5%")
.attr("stop-color","#E8E8E8");
linearGradient.append("stop")
.attr("offset","45%")
.attr("stop-color","#A3A3A3");
linearGradient.append("stop")
.attr("offset","55%")
.attr("stop-color","#A3A3A3");
linearGradient.append("stop")
.attr("offset","95%")
.attr("stop-color","#E8E8E8");
////////////////////////////////////////////////////////////
////////////////////////// Data ////////////////////////////
////////////////////////////////////////////////////////////
var Names = ["Administrative Staff","Crafts","Business Management","Basic Occupations","Health",
"IT","Juridical & Cultural","Management functions","Teachers",
"Salesmen & Service providers","Caretakers","Science & Engineering", "Other", "",
"Engineering","Education","Agriculture","Art, Language & Culture","Health","Behavior & Social Sciences","Economy",""];
var respondents = 17533, //Total number of respondents (i.e. the number that make up the total group
emptyPerc = 0.5, //What % of the circle should become empty
emptyStroke = Math.round(respondents*emptyPerc);
var matrix = [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,232,65,44,57,39,123,1373,0], //Administratief personeel
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,32,0,0,11,0,0,24,0], //Ambachtslieden
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,173,43,52,55,36,125,2413,0], //Bedrijfsbeheer (vak)specialisten
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,32,16,13,23,10,37,54,0], //Elementaire beroepen
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,161,24,17,0,2089,85,60,0], //Gezondheidszorg (vak)specialisten
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,510,0,0,57,0,0,251,0], //IT (vak)specialisten
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,16,118,10,454,99,1537,271,0], //Juridisch en culturele (vak)specialisten
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,76,21,10,15,125,41,261,0], //Leidinggevende functies
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,32,2206,37,292,32,116,76,0], //Onderwijsgevenden
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,96,74,43,116,51,135,752,0], //Verkopers en verleners persoonlijke diensten
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,34,0,22,27,156,36,0], //Verzorgend personeel
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1141,0,111,291,0,0,48,0], //Wetenschap en techniek (vak)specialisten
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,36,0,39,0,0,20,109,0], //Other
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,emptyStroke], //dummyBottom
[232,32,173,32,161,510,16,76,32,96,15,1141,36,0,0,0,0,0,0,0,0,0], //Techniek
[65,0,43,16,24,0,118,21,2206,74,34,0,0,0,0,0,0,0,0,0,0,0], //Onderwijs
[44,0,52,13,17,0,10,10,37,43,0,111,39,0,0,0,0,0,0,0,0,0], //Landbouw
[57,11,55,23,0,57,454,15,292,116,22,291,0,0,0,0,0,0,0,0,0,0], //Kunst, Taal en Cultuur
[39,0,36,10,2089,0,99,125,32,51,27,0,0,0,0,0,0,0,0,0,0,0], //Gezondheidszorg
[123,0,125,37,85,0,1537,41,116,135,156,0,20,0,0,0,0,0,0,0,0,0], //Gedrag & Maatschappij
[1373,24,2413,54,60,251,271,261,76,752,36,48,109,0,0,0,0,0,0,0,0,0], //Economie
[0,0,0,0,0,0,0,0,0,0,0,0,0,emptyStroke,0,0,0,0,0,0,0,0] //dummyTop
];
//Calculate how far the Chord Diagram needs to be rotated clockwise to make the dummy
//invisible chord center vertically
var offset = (2 * Math.PI) * (emptyStroke/(respondents + emptyStroke))/4;
//Custom sort function of the chords to keep them in the original order
var chord = customChordLayout() //d3.layout.chord()
.padding(.02)
.sortChords(d3.descending) //which chord should be shown on top when chords cross. Now the biggest chord is at the bottom
.matrix(matrix);
var arc = d3.svg.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius)
.startAngle(startAngle) //startAngle and endAngle now include the offset in degrees
.endAngle(endAngle);
var path = stretchedChord() //Call the stretched chord function
.radius(innerRadius)
.startAngle(startAngle)
.endAngle(endAngle)
.pullOutSize(pullOutSize);
////////////////////////////////////////////////////////////
//////////////////// Draw outer Arcs ///////////////////////
////////////////////////////////////////////////////////////
var g = wrapper.selectAll("g.group")
.data(chord.groups)
.enter().append("g")
.attr("class", "group")
.on("mouseover", fade(opacityLow))
.on("mouseout", fade(opacityDefault));
g.append("path")
.style("stroke", function(d,i) { return (Names[i] === "" ? "none" : "#00A1DE"); })
.style("fill", function(d,i) { return (Names[i] === "" ? "none" : "#00A1DE"); })
.style("pointer-events", function(d,i) { return (Names[i] === "" ? "none" : "auto"); })
.attr("d", arc)
.attr("transform", function(d, i) { //Pull the two slices apart
d.pullOutSize = pullOutSize * ( d.startAngle + 0.001 > Math.PI ? -1 : 1);
return "translate(" + d.pullOutSize + ',' + 0 + ")";
});
////////////////////////////////////////////////////////////
////////////////////// Append Names ////////////////////////
////////////////////////////////////////////////////////////
//The text also needs to be displaced in the horizontal directions
//And also rotated with the offset in the clockwise direction
g.append("text")
.each(function(d) { d.angle = ((d.startAngle + d.endAngle) / 2) + offset;})
.attr("dy", ".35em")
.attr("class", "titles")
.style("font-size", mobileScreen ? "8px" : "10px" )
.attr("text-anchor", function(d) { return d.angle > Math.PI ? "end" : null; })
.attr("transform", function(d,i) {
var c = arc.centroid(d);
return "translate(" + (c[0] + d.pullOutSize) + "," + c[1] + ")"
+ "rotate(" + (d.angle * 180 / Math.PI - 90) + ")"
+ "translate(" + 20 + ",0)"
+ (d.angle > Math.PI ? "rotate(180)" : "")
})
.text(function(d,i) { return Names[i]; })
.call(wrapChord, 100);
////////////////////////////////////////////////////////////
//////////////////// Draw inner chords /////////////////////
////////////////////////////////////////////////////////////
wrapper.selectAll("path.chord")
.data(chord.chords)
.enter().append("path")
.attr("class", "chord")
.style("stroke", "none")
.style("fill", "url(#animatedGradient)") //An SVG Gradient to give the impression of a flow from left to right
.style("opacity", function(d) { return (Names[d.source.index] === "" ? 0 : opacityDefault); }) //Make the dummy strokes have a zero opacity (invisible)
.style("pointer-events", function(d,i) { return (Names[d.source.index] === "" ? "none" : "auto"); }) //Remove pointer events from dummy strokes
.attr("d", path)
.on("mouseover", fadeOnChord)
.on("mouseout", fade(opacityDefault));
////////////////////////////////////////////////////////////
////////////////// Extra Functions /////////////////////////
////////////////////////////////////////////////////////////
//Include the offset in de start and end angle to rotate the Chord diagram clockwise
function startAngle(d) { return d.startAngle + offset; }
function endAngle(d) { return d.endAngle + offset; }
// Returns an event handler for fading a given chord group
function fade(opacity) {
return function(d, i) {
wrapper.selectAll("path.chord")
.filter(function(d) { return d.source.index !== i && d.target.index !== i && Names[d.source.index] !== ""; })
.transition()
.style("opacity", opacity);
};
}//fade
// Fade function when hovering over chord
function fadeOnChord(d) {
var chosen = d;
wrapper.selectAll("path.chord")
.transition()
.style("opacity", function(d) {
return d.source.index === chosen.source.index && d.target.index === chosen.target.index ? opacityDefault : opacityLow;
});
}//fadeOnChord
/*Taken from http://bl.ocks.org/mbostock/7555321
//Wraps SVG text*/
function wrapChord(text, width) {
text.each(function() {
var text = d3.select(this),
words = text.text().split(/\s+/).reverse(),
word,
line = [],
lineNumber = 0,
lineHeight = 1.1, // ems
y = 0,
x = 0,
dy = parseFloat(text.attr("dy")),
tspan = text.text(null).append("tspan").attr("x", x).attr("y", y).attr("dy", dy + "em");
while (word = words.pop()) {
line.push(word);
tspan.text(line.join(" "));
if (tspan.node().getComputedTextLength() > width) {
line.pop();
tspan.text(line.join(" "));
line = [word];
tspan = text.append("tspan").attr("x", x).attr("y", y).attr("dy", ++lineNumber * lineHeight + dy + "em").text(word);
}
}
});
}//wrapChord
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.