Skip to content

Instantly share code, notes, and snippets.

@jeinarsson
Last active July 17, 2019 15:56
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 jeinarsson/e37aa55c3b0e11ae6fa1 to your computer and use it in GitHub Desktop.
Save jeinarsson/e37aa55c3b0e11ae6fa1 to your computer and use it in GitHub Desktop.
pathSankey - Random data

pathSankey example - Random data

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;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment