Example of visualizing paths as flows through layers of nodes. The figure is discussed in detail in a blog post.
A smaller and simpler example of the re-usable chart is a neighbouring gist.
Example of visualizing paths as flows through layers of nodes. The figure is discussed in detail in a blog post.
A smaller and simpler example of the re-usable chart is a neighbouring gist.
<!DOCTYPE html> | |
<meta charset="utf-8"> | |
<head> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js"></script> | |
<script src="pathSankey.js"></script> | |
<link rel="stylesheet" href="style.css"> | |
</head> | |
<body> | |
<script> | |
var nominalWidth = 500; | |
var nominalHeight = 250; | |
var layerStructure = [3, 5, 3]; | |
var connectionProbability = 0.4; | |
var chart = d3.pathSankey() | |
.width(nominalWidth).height(nominalHeight) | |
.selectedNodeAddress([0,0,0]); | |
function initialize() { | |
d3.select("body") | |
.append("svg") | |
.attr("viewBox", "0 0 "+nominalWidth+" "+nominalHeight) | |
.attr("preserveAspectRatio","xMinYMin meet"); | |
var enteringInputGroups = d3.select("body").selectAll("input") | |
.data(layerStructure) | |
.enter() | |
.append("div"); | |
enteringInputGroups | |
.append("span").html(function(d,i) {return "Layer " + i + ": ";}); | |
enteringInputGroups | |
.append("input") | |
.attr("type", "number") | |
.attr("min", 1) | |
.attr("max", 20) | |
.attr("value", function(d) {return d;}) | |
.on("change", function(d,i) { | |
if (layerStructure[i] != this.value) { | |
layerStructure[i] = this.value; | |
bind(); | |
} | |
}); | |
enteringInputGroups | |
.append("span").html(" nodes."); | |
d3.select("body") | |
.append("input") | |
.attr("type", "button") | |
.attr("value", "Randomize data") | |
.on("click", bind); | |
bind(); | |
d3.select(window).on("resize",draw); | |
} | |
function bind() { | |
var data = makeRandomData(layerStructure); | |
d3.select("svg").datum(data); | |
draw(); | |
} | |
function draw() { | |
// find inner width of container element (body) | |
var cs = window.getComputedStyle(d3.select('body').node()); | |
var width = parseInt(cs.width) - parseInt(cs.paddingLeft) - parseInt(cs.paddingRight); | |
width = width > nominalWidth ? nominalWidth : width; | |
var height = Math.round(width * nominalHeight/nominalWidth); | |
// set width/height of svg element, svg has viewBox so elements will just scale down | |
d3.select("svg") | |
.html("") | |
.attr("width", width) | |
.attr("height", height) | |
.call(chart); | |
} | |
function makeRandomData(layers) { | |
function randInt(min, max) { | |
return Math.round(Math.random() * (max - min)) + min; | |
} | |
var numLayers = layers.length; | |
var ret = {nodes: [], flows: []}; | |
// make up some nodes | |
for (var i=0; i < numLayers; i++) { | |
var nodes = []; | |
var group = {items: nodes, title: "", label:0}; | |
var layer = {items: [group], title: "Layer "+i, x: i/(numLayers-1)}; | |
var numNodes = layers[i]; | |
for (var j=0; j < numNodes; j++) { | |
nodes.push({title: "Node "+(i*numLayers+j), color: d3.rgb(randInt(20,200), randInt(20,200), randInt(20,200))}); | |
} | |
ret.nodes.push(layer); | |
} | |
// make up some flows | |
function getPathsRecursively(lists, n) { | |
if (n === -1) { | |
return [[]]; | |
} | |
var restOfPaths = getPathsRecursively(lists, n-1); | |
var paths = []; | |
restOfPaths.forEach(function(rest) { | |
lists[n].forEach(function(d,i) { | |
paths.push(rest.concat([[n,0,i]])); | |
}); | |
}); | |
return paths; | |
} | |
function getPaths(lists) { | |
return getPathsRecursively(lists, lists.length-1); | |
} | |
var allPaths = getPaths(ret.nodes.map(function(d) {return d.items[0].items;})); | |
allPaths.forEach(function(path){ | |
if (Math.random() < connectionProbability) { | |
ret.flows.push({magnitude: randInt(10,250), path: path}); | |
} | |
}); | |
return ret; | |
} | |
initialize(); | |
</script> | |
</body> |
The MIT License (MIT) | |
Copyright (c) 2015 Jonas Einarsson | |
Permission is hereby granted, free of charge, to any person obtaining a copy | |
of this software and associated documentation files (the "Software"), to deal | |
in the Software without restriction, including without limitation the rights | |
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
copies of the Software, and to permit persons to whom the Software is | |
furnished to do so, subject to the following conditions: | |
The above copyright notice and this permission notice shall be included in all | |
copies or substantial portions of the Software. | |
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
SOFTWARE. |
d3.pathSankey = function() { | |
/* | |
Split SVG text into several <tspan> where | |
string has newline character \n | |
Based on http://bl.ocks.org/mbostock/7555321 | |
*/ | |
function linebreak(text) { | |
text.each(function() { | |
var text = d3.select(this), | |
words = text.text().split(/\n/).reverse(), | |
word, | |
lineNumber = 0, | |
lineHeight = 1.1, // ems | |
y = text.attr("y"), | |
x = text.attr("x"), | |
dx = text.attr("dx"), | |
dy = 0.3 - (words.length-1)*lineHeight*0.5; //ems | |
text.text(null); | |
while (word = words.pop()) { | |
tspan = text.append("tspan").attr("dx",dx).attr("x", x).attr("y", y).attr("dy", lineNumber++ * lineHeight + dy + "em").text(word); | |
} | |
}); | |
} | |
function prop(p) { | |
return function(d) { | |
return d[p]; | |
}; | |
} | |
var width, height; // total width including padding | |
var onNodeSelected, onNodeDeselected; // callbacks | |
var labelspace = {top:50,left:30,right:30,bottom:0}; // padding around actual sankey | |
var selectedNodeAddress = null; | |
var nodeYSpacing = 3, | |
nodeGroupYSpacing = 0; | |
var nodeGroupYPadding = 10; | |
var nodeWidth = 30; | |
var groupLabelDistance = 5; | |
var flowStartWidth = 20; // flows go horizontally for this distance before curving | |
function chart(selection) { | |
selection.each(function(data){ | |
var parent = d3.select(this); | |
var yscale; // not a d3.scale, just a number | |
var currentlyActive = null; // node | |
var availableWidth = width - (labelspace.right+labelspace.left); | |
var availableHeight = height - (labelspace.top + labelspace.bottom); | |
var flowAreasData = []; | |
/* | |
The following anonymous function is used to scope the algorithm for | |
preparing the data. | |
It computes sizes and positions for all nodes | |
and flows and saves them *on* original data structure. | |
It does not mutate original data (because then multiple call() would | |
destroy the chart.) | |
*/ | |
(function() { | |
var nodes = data.nodes; | |
var flows = data.flows; | |
// reset counters from any previous render | |
nodes.forEach(function(layer){ | |
layer.size = layer.sizeIn = layer.sizeOut = 0; | |
layer.items.forEach(function(group){ | |
group.size = group.sizeIn = group.sizeOut = 0; | |
group.items.forEach(function(node){ | |
node.size = node.sizeIn = node.sizeOut = 0; | |
node.filledOutY = 0; | |
node.filledInY = 0; | |
}); | |
}); | |
}); | |
// compute and store sizes of all layers, groups and nodes by counting flows through them | |
flows.forEach(function(flow){ | |
flow.path.forEach(function(p,i) { | |
var layer = nodes[p[0]]; | |
var nodeGroup = layer.items[p[1]]; | |
var node = nodeGroup.items[p[2]]; | |
if (i > 0) { | |
layer.sizeIn += flow.magnitude; | |
nodeGroup.sizeIn += flow.magnitude; | |
node.sizeIn += flow.magnitude; | |
} | |
if (i < flow.path.length-1) { | |
layer.sizeOut += flow.magnitude; | |
nodeGroup.sizeOut += flow.magnitude; | |
node.sizeOut += flow.magnitude; | |
} | |
}); | |
}); | |
nodes.forEach(function(layer){ | |
layer.size = d3.max([layer.sizeIn, layer.sizeOut]); | |
layer.items.forEach(function(group){ | |
group.size = d3.max([group.sizeIn, group.sizeOut]); | |
group.items.forEach(function(node){ | |
node.size = d3.max([node.sizeIn, node.sizeOut]); | |
}); | |
}); | |
}); | |
nodes.forEach(function(layer){ | |
layer.numNodeSpacings = d3.sum(layer.items, function(g){return g.items.length-1;}); | |
layer.numGroupSpacings = layer.items.length-1; | |
}); | |
// yscale calibrated to fill available height according to equation: | |
// availableHeight == size*yscale + group_spacing + group_padding + node_spacing | |
// (take worst case: smallest value) | |
yscale = d3.min(nodes, function(d){ | |
return (availableHeight | |
- d.numGroupSpacings*nodeGroupYSpacing | |
- d.items.length*nodeGroupYPadding*2 | |
- d.numNodeSpacings*nodeYSpacing)/d.size; | |
}); | |
// compute layer heights by summing all sizes and spacings | |
nodes.forEach(function(layer){ | |
layer.totalHeight = layer.size * yscale | |
+ layer.numGroupSpacings*nodeGroupYSpacing | |
+ layer.items.length*nodeGroupYPadding*2 | |
+ layer.numNodeSpacings*nodeYSpacing; | |
}); | |
// use computed sizes to compute positions of all layers, groups and nodes | |
nodes.forEach(function(layer, layerIdx){ | |
var y = 0.5*(availableHeight-layer.totalHeight) + labelspace.top; | |
layer.y = y; | |
layer.items.forEach(function(group, groupIdx){ | |
group.x = labelspace.left+(availableWidth-nodeWidth)*layer.x; | |
group.y = y; | |
y += nodeGroupYPadding; | |
group.items.forEach(function(node, nodeIdx){ | |
node.x = group.x; | |
node.y = y; | |
y += node.size * yscale; | |
node.height = y - node.y; | |
y += nodeYSpacing; | |
node.layerIdx = layerIdx; | |
node.groupIdx = groupIdx; | |
node.nodeIdx = nodeIdx; | |
node.uniqueId = [layerIdx, groupIdx, nodeIdx].join("-"); | |
// convernt string colors and set a default color | |
// todo: where should this go? | |
if (node.color.length) { | |
node.color = d3.hsl(node.color); | |
} | |
if (!node.color) node.color = d3.hsl("#aaa"); | |
}); | |
y -= nodeYSpacing; | |
y += nodeGroupYPadding; | |
group.height = y - group.y; | |
y += nodeGroupYSpacing; | |
}); | |
y -= nodeGroupYSpacing; | |
}); | |
/* | |
Compute all the path data for the flows. | |
First make a deep copy of the flows data because | |
algorithm is destructive | |
*/ | |
var flowsCopy = data.flows.map(function(f){ | |
var f2 = {magnitude: f.magnitude}; | |
f2.extraClasses = f.path.map(function(addr){return "passes-"+addr.join("-");}).join(" "); | |
f2.path = f.path.map(function(addr){ | |
return addr.slice(0); | |
}); | |
return f2; | |
}); | |
while(true) { | |
flowsCopy = flowsCopy.filter(function(d){return d.path.length > 1;}); | |
if (flowsCopy.length === 0) return; | |
flowsCopy.sort(function(a,b){ | |
return a.path[0][0]-b.path[0][0] | |
|| a.path[0][1]-b.path[0][1] | |
|| a.path[0][2]-b.path[0][2] | |
|| a.path[1][0]-b.path[1][0] | |
|| a.path[1][1]-b.path[1][1] | |
|| a.path[1][2]-b.path[1][2]; | |
}); | |
var layerIdx = flowsCopy[0].path[0][0]; | |
flowsCopy.forEach(function(flow){ | |
if (flow.path[0][0] != layerIdx) return; | |
var from = flow.path[0]; | |
var to = flow.path[1]; | |
var h = flow.magnitude*yscale; | |
var source = nodes[from[0]].items[from[1]].items[from[2]]; | |
var target = nodes[to[0]].items[to[1]].items[to[2]]; | |
var sourceY0 = source.filledOutY || source.y; | |
var sourceY1 = sourceY0 + h; | |
source.filledOutY = sourceY1; | |
var targetY0 = target.filledInY || target.y; | |
var targetY1 = targetY0 + h; | |
target.filledInY = targetY1; | |
flowAreasData.push({ | |
area: [ | |
{x: source.x+nodeWidth, y0: sourceY0, y1: sourceY1}, | |
{x: source.x+nodeWidth+flowStartWidth, y0: sourceY0, y1: sourceY1}, | |
{x: target.x-flowStartWidth, y0: targetY0, y1: targetY1}, | |
{x: target.x, y0: targetY0, y1: targetY1}, | |
], | |
class: ["flow", flow.extraClasses].join(" ") | |
}); | |
flow.path.shift(); | |
}); | |
} | |
})(); // end of data preparation | |
// Create all svg elements: layers, groups, nodes and flows. | |
var nodeLayers = parent.selectAll(".node-layers") | |
.data(prop("nodes")); | |
// layer label positioning functions | |
layerLabelx = function(d){return labelspace.left+d.x*(availableWidth-nodeWidth)+0.5*nodeWidth;}; | |
layerLabely = function(d){return 0.5*labelspace.top;}; | |
nodeLayers.enter() | |
.append("g").classed("node-layer",true) | |
.append("text") | |
.attr("class", "layer-label") | |
.attr("text-anchor","middle") | |
.attr("dx",0) | |
.attr("dy",0); | |
nodeLayers.selectAll("text") | |
.attr("x", layerLabelx) | |
.attr("y", layerLabely) | |
.text(prop("title")).call(linebreak); | |
nodeLayers.exit().remove(); | |
var nodeGroups = nodeLayers.selectAll("g.node-group").data(prop("items")); | |
var enteringNodeGroups = nodeGroups.enter().append("g").classed("node-group", true); | |
enteringNodeGroups.append("rect").classed("node-group", true); | |
var enteringNodeGroupsG = enteringNodeGroups.append("g").attr("class","node-group-label"); | |
enteringNodeGroupsG.append("path"); | |
enteringNodeGroupsG.append("text"); | |
nodeGroups.selectAll("g.node-group > rect") | |
.attr("x", prop("x")) | |
.attr("y", prop("y")) | |
.attr("width", nodeWidth) | |
.attr("height", prop("height")); | |
nodeGroups.selectAll("g.node-group > g") | |
.style("display",function(d){return d.label ? "" : "none";}); | |
// node group label position functions | |
nodeGroupLabelx = function(d){return d.x+0.5*nodeWidth+0.5*d.label*nodeWidth;}; | |
nodeGroupLabely = function(d){return d.y + 0.5*d.height;}; | |
nodeGroups.selectAll("g.node-group > g > path") | |
.attr("d", function(d){ | |
return d3.svg.line()([ | |
[nodeGroupLabelx(d)+groupLabelDistance*d.label ,d.y+nodeGroupYPadding], | |
[nodeGroupLabelx(d)+groupLabelDistance*d.label ,d.y+d.height-nodeGroupYPadding] | |
]);}); | |
nodeGroups.selectAll("g.node-group > g > text") | |
.attr("text-anchor",function(d) {return d.label == -1 ? "end" : "start";}) | |
.attr("dx",function(d){return d.label*(groupLabelDistance*2);}) | |
.attr("dy","0.3em") | |
.attr("x", nodeGroupLabelx) | |
.attr("y", nodeGroupLabely) | |
.text(prop("title")).call(linebreak); | |
nodeGroups.exit().remove(); | |
var flowElements = parent.selectAll("path.flow").data(flowAreasData); | |
flowElements.enter().append("path").attr("class", prop("class")); | |
flowElements | |
.datum(prop("area")) | |
.attr("d", | |
d3.svg.area() | |
.x(prop("x")) | |
.y0(prop("y0")) | |
.y1(prop("y1")) | |
.interpolate("basis")); | |
flowElements.exit().remove(); | |
function activateNode(d){ | |
var node_id = d.uniqueId; | |
var theflows, thenode; | |
if (currentlyActive) { | |
if (onNodeDeselected) onNodeDeselected(currentlyActive.d); | |
theflows = parent.selectAll(".passes-"+currentlyActive.id); | |
thenode = parent.selectAll(".node-"+currentlyActive.id); | |
theflows | |
.style("fill", null) | |
.style("fill-opacity", null); | |
if (currentlyActive.id == node_id) { | |
currentlyActive = selectedNodeAddress = null; | |
return; | |
} | |
} | |
theflows = parent.selectAll(".passes-"+node_id); | |
thenode = parent.selectAll(".node-"+node_id); | |
theflows.transition() | |
.style("fill", d.color) | |
.style("fill-opacity", 1.0); | |
thenode.style("fill", d.color); | |
currentlyActive = {"id": node_id, "d": d}; | |
selectedNodeAddress = node_id.split("-").map(function(d){return parseInt(d);}); | |
if (onNodeSelected) onNodeSelected(d); | |
} | |
function mouseoverNode(d) { | |
if (currentlyActive && currentlyActive.id == d.uniqueId) { | |
return; | |
} | |
d3.select(this).style("fill", d.color.brighter()); | |
} | |
function mouseoutNode(d) { | |
if (currentlyActive && currentlyActive.id == d.uniqueId) { | |
return; | |
} | |
d3.select(this).style("fill", d.color); | |
} | |
var nodeElements = nodeGroups.selectAll("rect.node").data(prop("items")); | |
nodeElements.enter().append("rect").attr("class", function(d){return "node node-"+d.uniqueId;}); | |
nodeElements | |
.attr("x", prop("x")) | |
.attr("y", prop("y")) | |
.attr("width", nodeWidth) | |
.attr("height",prop("height")) | |
.style("fill", function(d){return d.color;}) | |
.on("mouseover", mouseoverNode) | |
.on("mouseout", mouseoutNode) | |
.on("click", activateNode); | |
nodeElements.exit().remove(); | |
if (selectedNodeAddress) { | |
var node = data.nodes[selectedNodeAddress[0]] | |
.items[selectedNodeAddress[1]] | |
.items[selectedNodeAddress[2]]; | |
activateNode(node); | |
} | |
}); // selection.each() | |
} | |
chart.width = function(_) { | |
if (!arguments.length) return width; | |
else width = +_; | |
return chart; | |
}; | |
chart.height = function(_) { | |
if (!arguments.length) return height; | |
else height = +_; | |
return chart; | |
}; | |
chart.onNodeSelected = function(_) { | |
if (!arguments.length) return onNodeSelected; | |
else onNodeSelected = _; | |
return chart; | |
}; | |
chart.onNodeDeselected = function(_) { | |
if (!arguments.length) return onNodeDeselected; | |
else onNodeDeselected = _; | |
return chart; | |
}; | |
chart.selectedNodeAddress = function(_) { | |
if (!arguments.length) return selectedNodeAddress; | |
else selectedNodeAddress = _; | |
return chart; | |
}; | |
chart.labelSpaceLeft = function(_) { | |
if (!arguments.length) return labelspace.left; | |
else labelspace.left = _; | |
return chart; | |
}; | |
chart.labelSpaceRight = function(_) { | |
if (!arguments.length) return labelspace.right; | |
else labelspace.right = _; | |
return chart; | |
}; | |
return chart; | |
}; | |
rect.node { | |
stroke: none; | |
cursor: pointer; | |
} | |
rect.node-group { | |
display:none; | |
} | |
.flow { | |
fill: #000; | |
fill-opacity: .2;; | |
} | |
.node-group-label path { | |
fill: none; | |
stroke-width: 3px; | |
stroke: #777; | |
} | |
.node-group-label text { | |
font-size: 12px; | |
font-weight: 600; | |
cursor: default; | |
} | |
.layer-label { | |
font-size: 14px; | |
font-weight: 600; | |
cursor:default; | |
} | |
body { | |
font-family: sans-serif; | |
} | |
input { | |
margin: 3px 3px; | |
padding: 5px; | |
font-weight: bold; | |
} |