This hybrid sankey diagram builds on the example found at http://www.d3noob.org/2013/02/sankey-diagrams-description-of-d3js-code.html .
It modifies the original sankey API found at: https://github.com/d3/d3-plugins/tree/master/sankey .
I imagine using it in a site visit mapping context, superceding the visit mapping found in products from that company in San Jose.
Share of visit traffic (for example, by device type) can be distinguished by link colors.
Types of page can be distinguished by node color, while the exact page name is given in the text.
In real life, you'll generate 10Ks of paths and paths of 100s of distinct node types easily. So you'll need to curate the nodes and links to a manageable set. Some python efforts to do that are in my repo at: https://github.com/mgoold/sankeyhybrid .
Last active
February 29, 2016 16:33
-
-
Save mgoold/95d3969755a9b2d35f09 to your computer and use it in GitHub Desktop.
Site Visits Page Sequence Map Prototype
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
d3.sankey = function() { | |
var nodetypearray2=[]; | |
var sankey = {}, | |
nodeWidth = 24, | |
nodePadding = 10, | |
size = [1, 1], | |
nodes = [], | |
links = []; | |
sankey.nodeWidth = function(_) { | |
if (!arguments.length) return nodeWidth; | |
nodeWidth = +_; | |
return sankey; | |
}; | |
sankey.nodePadding = function(_) { | |
if (!arguments.length) return nodePadding; | |
nodePadding = +_; | |
return sankey; | |
}; | |
sankey.nodes = function(_) { | |
if (!arguments.length) return nodes; | |
nodes = _; | |
return sankey; | |
}; | |
sankey.links = function(_) { | |
if (!arguments.length) return links; | |
links = _; | |
return sankey; | |
}; | |
sankey.size = function(_) { | |
if (!arguments.length) return size; | |
size = _; | |
return sankey; | |
}; | |
sankey.layout = function(iterations) { | |
computeNodeLinks(); | |
computeNodeValues(); | |
computeNodeBreadths(); | |
computeNodeDepths(iterations); | |
computeLinkDepths(); | |
return sankey; | |
}; | |
sankey.relayout = function() { | |
computeLinkDepths(); | |
return sankey; | |
}; | |
function clone(obj) { | |
if (null == obj || "object" != typeof obj) return obj; | |
var copy = obj.constructor(); | |
for (var attr in obj) { | |
if (obj.hasOwnProperty(attr)) copy[attr] = obj[attr]; | |
} | |
return copy; | |
} | |
sankey.link = function() { | |
var curvature = .5; | |
function link(d) { | |
// console.log('d',d); | |
var x0 = d.source.x + d.source.dx, | |
x1 = d.target.x, | |
xi = d3.interpolateNumber(x0, x1), | |
x2 = xi(curvature), | |
x3 = xi(1 - curvature), | |
y0 = d.source.y + d.sy + d.dy / 2, | |
y1 = d.target.y + d.ty + d.dy / 2; | |
return "M" + x0 + "," + y0 | |
+ "C" + x2 + "," + y0 | |
+ " " + x3 + "," + y1 | |
+ " " + x1 + "," + y1; | |
} | |
link.curvature = function(_) { | |
if (!arguments.length) return curvature; | |
curvature = +_; | |
return link; | |
}; | |
return link; | |
}; | |
function computeNodeLinks() { | |
nodes.forEach(function(node) { | |
node.sourceLinks = []; | |
node.targetLinks = []; | |
}); | |
links.forEach(function(link) { | |
var source = link.source, | |
target = link.target; | |
if (typeof source === "number") { | |
var targetLink=clone(source); | |
source = link.source = nodes[link.source]; | |
} | |
if (typeof target === "number") { | |
nodes[link.target].targetLink=targetLink; | |
target = link.target = nodes[link.target]; | |
} | |
source.sourceLinks.push(link); | |
target.targetLinks.push(link); | |
}); | |
} | |
function computeNodeValues() { | |
nodes.forEach(function(node) { | |
node.value = Math.max( | |
d3.sum(node.sourceLinks, value), | |
d3.sum(node.targetLinks, value) | |
); | |
}); | |
} | |
//~ console.log('graph',graph); | |
function computeNodeBreadths() { | |
var remainingNodes = nodes, | |
nextNodes, | |
x = .6; | |
while (remainingNodes.length) { | |
nextNodes = []; | |
remainingNodes.forEach(function(node) { | |
node.x = x; | |
node.dx = nodeWidth; | |
node.sourceLinks.forEach(function(link) { | |
nextNodes.push(link.target); | |
}); | |
}); | |
remainingNodes = nextNodes; | |
++x; | |
} | |
// moveSinksRight(x); --disabling this call is all that's required to give the chart | |
// the "each step right is a visit" look of a visits chart | |
scaleNodeBreadths((width - nodeWidth) / (x - 1)); | |
} | |
function moveSourcesRight() { | |
nodes.forEach(function(node) { | |
if (!node.targetLinks.length) { | |
node.x = d3.min(node.sourceLinks, function(d) { return d.target.x; }) - 1; | |
} | |
}); | |
} | |
function moveSinksRight(x) { | |
nodes.forEach(function(node) { | |
if (!node.sourceLinks.length) { | |
node.x = x - 1; | |
} | |
}); | |
} | |
function scaleNodeBreadths(kx) { | |
nodes.forEach(function(node) { | |
node.x *= kx; | |
}); | |
} | |
var highestnode=0; | |
var lowestnode=0; | |
var highestrank=0; | |
var lowestrank=0; | |
function computeNodeDepths(iterations) { | |
var nodesByBreadth = d3.nest() | |
.key(function(d) {return d.x; }).sortKeys(d3.ascending) | |
.entries(nodes) | |
.map(function(d) {return d.values; }); | |
initializeNodeDepth(); | |
console.log('highestnode',highestnode,'lowestnode',lowestnode,'highestrank',highestrank); | |
console.log('nodesbybreadth', nodesByBreadth); | |
console.log('size',size); | |
var nodefloor=0; | |
for (i2=0; i2<50; i2+=1) { | |
nodefloor=nodes[lowestnode].y+nodes[lowestnode].dy; //the bottom of the lowest node. | |
positionnodes(nodePadding); | |
nodePadding*=.99 | |
} | |
function positionnodes(nodePadding) { | |
relaxRightToLeft2(nodePadding); | |
relaxLefttoRight(nodePadding); | |
} | |
function initializeNodeDepth() { | |
var ky = d3.min(nodesByBreadth, function(nodes) { | |
return (size[1] - (nodes.length - 1) * nodePadding) / d3.sum(nodes, value); | |
}); | |
var rankcount=0; | |
var prevnodecount=0; | |
var rankSum=0; | |
nodesByBreadth.forEach(function(nodes) { | |
nodes.forEach(function(node, i) { | |
node.dy = node.value * ky; | |
}); | |
nodes.forEach(function(node, i) { | |
if (node.targetLinks.length) { | |
var targetsum=0; | |
node.targetLinks.forEach(function (obj) { //for each node, sum the value of its lefthand links | |
targetsum+=obj.source.dy; | |
node.targeti=obj.source.i; //this is the | |
}); | |
node.targetsum=targetsum; | |
} else { | |
node.targetsum=node.dy; | |
node.targeti=i; | |
}; | |
node.sourcelinkcount=node.sourceLinks.length-1; | |
}); | |
if (rankcount<1) { | |
nodes.sort( | |
firstBy(function (a, b) { return b.targetsum-a.targetsum; }) | |
); | |
} else { | |
nodes.sort( | |
firstBy(function (a, b) { return a.targeti-b.targeti; }) | |
.thenBy(function (a, b) { return b.dy-a.dy; }) | |
); | |
} | |
var templinkrank = 0; | |
var temptarget = 0; | |
var nodecount=-1; | |
nodes.forEach(function (node) {++nodecount;}); | |
nodes.forEach(function(node, i) { | |
var j=0; | |
// console.log('node',node); | |
node.i=i; | |
if (node.targeti==0 && node.i==0) { //if the left hand node you link to is 0, that means you're the highest in your stack | |
templinkrank = j; | |
temptarget = node.sourcei; | |
highestnode=node.node; | |
} else { | |
if (temptarget == node.targeti) { //if next node shares same link, then increment templinkrank (link rank w/in same node) | |
templinkrank += 1; | |
} else { | |
temptarget = node.targeti; //else the new temp target is whatever left hand node goes with this node | |
templinkrank=0; | |
} | |
} | |
if (i==nodecount && node.targeti>=prevnodecount) { //conversely, if you're the last node and you link to the lowest node to your left, you're the new lowest | |
lowestnode=node.node; | |
prevnodecount=nodecount; | |
} | |
j+=1; | |
node.templinkrank=templinkrank; | |
}); | |
rankcount+=1; | |
}); | |
links.forEach(function(link) { | |
link.dy = link.value * ky; | |
}); | |
// console.log('highestnode',highestnode,'lowestnode',lowestnode,'highestrank',highestrank); | |
} | |
var firstY, temprankSum; | |
function relaxRightToLeft2(nodePadding) { //whole point of this function is assigning a summed spatial requirement to each node. this sum is sum of reqs to its right | |
// console.log('nodePadding',nodePadding); | |
nodesByBreadth.slice().reverse().forEach(function(subnodes) { | |
var thisranksum=0; | |
subnodes.forEach(function(node) { | |
var ranksum=0; | |
node.hassourcelinks=0; | |
if (node.sourceLinks.length>0) { | |
var nodectr=[]; | |
node.sourceLinks.forEach(function(obj) { | |
// console.log('nodectr.indexOf(obj.node)',obj,obj.target.node,nodectr.indexOf(obj.target.node)); | |
if (nodectr.indexOf(obj.target.node)==-1) { | |
// console.log('adding target ranksum',obj.target.node,obj.target.ranksum); | |
ranksum +=obj.target.ranksum; | |
nodectr.push(obj.target.node); | |
} | |
//bc we are summing the sizes of the links, should | |
//~ ranksum +=obj.target.ranksum; | |
}); | |
// console.log('nodectr',node.node,nodectr.length-1); | |
// console.log('node ranksum',node.node,ranksum); | |
ranksum += (nodectr.length-1) * nodePadding; | |
node.ranksum=ranksum; | |
node.hassourcelinks=1; | |
node.sourceLinks.forEach(function(obj) { | |
nodes[obj.target.node].prevranksum=ranksum; | |
}); | |
} else { | |
node.ranksum=node.dy; | |
} | |
thisranksum+=ranksum; | |
}); | |
subnodes.forEach(function(node) { | |
node.thisranksum=thisranksum; | |
}); | |
}); | |
} | |
function relaxLefttoRight(nodePadding) { | |
var j=0; | |
nodesByBreadth.slice().forEach(function(subnodes) { | |
var tempY=0; | |
subnodes.forEach(function(node) { | |
// console.log('node',node); | |
if (j==0) { | |
node.y=size[1]/2-node.dy/2; | |
} else { | |
if (node.i==0) { | |
node.y=node.sourcey+node.sourcedy/2-node.prevranksum/2+node.ranksum/2-node.dy/2; | |
} else { | |
node.y=tempY+node.ranksum/2-node.dy/2; | |
} | |
tempY=node.y+node.dy/2+node.ranksum/2+nodePadding; | |
} | |
node.sourceLinks.forEach(function(obj) { | |
nodes[obj.target.node].sourcedy=node.dy; | |
nodes[obj.target.node].sourcey=node.y; | |
}); | |
}) | |
j+=1; | |
}); | |
} | |
} | |
function computeLinkDepths() { | |
nodes.forEach(function(node) { | |
node.sourceLinks.sort(ascendingTargetDepth); | |
node.targetLinks.sort(ascendingSourceDepth); | |
}); | |
nodes.forEach(function(node) { | |
var sy = 0, ty = 0; | |
node.sourceLinks.forEach(function(link) { | |
link.sy = sy; | |
sy += link.dy; | |
}); | |
node.targetLinks.forEach(function(link) { | |
link.ty = ty; | |
ty += link.dy; | |
}); | |
}); | |
function ascendingSourceDepth(a, b) { | |
return a.source.y - b.source.y; | |
} | |
function ascendingTargetDepth(a, b) { | |
return a.target.y - b.target.y; | |
} | |
} | |
function center(node) { | |
var ycenter=node.y + node.dy / 2 | |
return node.y + node.dy / 2; | |
} | |
function value(link) { | |
return link.value; | |
} | |
firstBy=(function(){function e(f){f.thenBy=t;return f}function t(y,x){x=this;return e(function(a,b){return x(a,b)||y(a,b)})}return e})(); | |
return sankey; | |
}; | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"attributeinfo": { | |
"attributename":"Device Type", | |
"attributearray":["Desktop","Tablet"] | |
}, | |
"nodeinfo": { | |
"nodetype":"Page Name", | |
"nodetypearray":["Home Page","Offer Page","Signup Page","Search Page","Product Marketing Page","Purchase Page","Hard Bounce"] | |
}, | |
"nodes":[ | |
{"node":0,"name":"Home Page","nodetype":"Home Page"}, | |
{"node":1,"name":"Offer Type 1","nodetype":"Offer Page"}, | |
{"node":2,"name":"Offer Type 2","nodetype":"Offer Page"}, | |
{"node":3,"name":"Sign-up Page","nodetype":"Signup Page"}, | |
{"node":4,"name":"Main Search Page","nodetype":"Search Page"}, | |
{"node":5,"name":"Sign-up Page","nodetype":"Signup Page"}, | |
{"node":6,"name":"Product Offerings","nodetype":"Product Marketing Page"}, | |
{"node":7,"name":"Purchase Page","nodetype":"Purchase Page"}, | |
{"node":8,"name":"Sign-up Page","nodetype":"Signup Page"}, | |
{"node":9,"name":"Purchase Page","nodetype":"Purchase Page"}, | |
{"node":10,"name":"Home Page","nodetype":"Home Page"}, | |
{"node":11,"name":"Hard Bounce","nodetype":"Hard Bounce"}, | |
{"node":12,"name":"Hard Bounce","nodetype":"Hard Bounce"}, | |
{"node":13,"name":"Offer Type 2","nodetype":"Offer Page"}, | |
{"node":14,"name":"Sign-up Page","nodetype":"Signup Page"} | |
], | |
"links":[ | |
{"source":0,"target":1,"value":400,"attrib":"Desktop"}, | |
{"source":0,"target":1,"value":200,"attrib":"Tablet"}, | |
{"source":0,"target":2,"value":800,"attrib":"Desktop"}, | |
{"source":0,"target":2,"value":400,"attrib":"Tablet"}, | |
{"source":1,"target":3,"value":200,"attrib":"Desktop"}, | |
{"source":1,"target":3,"value":100,"attrib":"Tablet"}, | |
{"source":1,"target":4,"value":100,"attrib":"Desktop"}, | |
{"source":1,"target":4,"value":50,"attrib":"Tablet"}, | |
{"source":1,"target":12,"value":100,"attrib":"Desktop"}, | |
{"source":1,"target":12,"value":50,"attrib":"Tablet"}, | |
{"source":2,"target":5,"value":400,"attrib":"Desktop"}, | |
{"source":2,"target":5,"value":200,"attrib":"Tablet"}, | |
{"source":2,"target":6,"value":400,"attrib":"Desktop"}, | |
{"source":2,"target":6,"value":200,"attrib":"Tablet"}, | |
{"source":5,"target":7,"value":200,"attrib":"Desktop"}, | |
{"source":5,"target":7,"value":100,"attrib":"Tablet"}, | |
{"source":5,"target":8,"value":200,"attrib":"Desktop"}, | |
{"source":5,"target":8,"value":100,"attrib":"Tablet"}, | |
{"source":6,"target":9,"value":400,"attrib":"Desktop"}, | |
{"source":6,"target":9,"value":200,"attrib":"Tablet"}, | |
{"source":9,"target":10,"value":200,"attrib":"Desktop"}, | |
{"source":9,"target":10,"value":100,"attrib":"Tablet"}, | |
{"source":9,"target":11,"value":200,"attrib":"Desktop"}, | |
{"source":9,"target":11,"value":100,"attrib":"Tablet"}, | |
{"source":0,"target":13,"value":100,"attrib":"Desktop"}, | |
{"source":0,"target":13,"value":40,"attrib":"Tablet"}, | |
{"source":13,"target":14,"value":100,"attrib":"Desktop"}, | |
{"source":13,"target":14,"value":40,"attrib":"Tablet"} | |
]} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<meta charset="utf-8"> | |
<title>Modified Sankey for Site Traffic Tracking</title> | |
<style> | |
.node rect { | |
cursor: move; | |
fill-opacity: .9; | |
shape-rendering: crispEdges; | |
} | |
.node text { | |
font: "Arial"; | |
pointer-events: none; | |
text-shadow: 0 1px 0 #fff; | |
} | |
.path { | |
fill: none; | |
stroke-opacity: .2; | |
} | |
.link:hover { | |
stroke-opacity: .5; | |
} | |
</style> | |
<body> | |
<div> | |
<div id="linklegend" style="float:left"></div> | |
<div id="chart" style="float:left"></div> | |
</div> | |
<script src="//d3js.org/d3.v3.min.js" charset="utf-8"></script> | |
<script src="hybridsankey.js"></script> | |
<script> | |
var pathdata=[{"x":100,"y":100}]; | |
var units = "Visits"; | |
var margin = {top: 10, right: 10, bottom: 10, left: 10}; | |
var linklegwidth=80; | |
var width = 850 - margin.left - margin.right; | |
var height = 500 - margin.top - margin.bottom; | |
var formatNumber = d3.format(",.0f"), | |
format = function(d) { return formatNumber(d) + " " + units; }, | |
color = d3.scale.category20(); | |
//http://stackoverflow.com/questions/17217766/two-divs-side-by-side-fluid-display | |
var svg = d3.select("#chart").append("svg") | |
.attr("width", width + margin.left + margin.right) | |
.attr("height", height + margin.top + margin.bottom) | |
//.attr("style", "outline: thin solid red;") | |
.append("g") | |
.attr("transform", | |
"translate(" + margin.left + "," + margin.top + ")"); | |
var legsvg = d3.select("#linklegend").append("svg") | |
.attr("width", linklegwidth + margin.left + margin.right) | |
.attr("height", height + margin.top + margin.bottom) | |
//.attr("style", "outline: thin solid red;") | |
.append("g") | |
.attr("transform", | |
"translate(" + margin.left + "," + margin.top + ")"); | |
// Set the sankey diagram properties | |
var sankey = d3.sankey() | |
.nodeWidth(36) | |
.nodePadding(40) | |
.size([width, height]); | |
var path = sankey.link(); | |
var bouncecolor="#FF0000" | |
var linkcoldomain=["#0033cc","#ff6600","#0099cc"]; | |
var nodecoldomain=["#CC9900","#99CCFF","#CCFFCC","#9900cc","#cc9900","#00cc99","#993300","#009933","#66ccff","#ff9900","#00ff99","#9900ff","#0066ff"]; | |
// load the data | |
d3.json("hybridsankey.json", function(error, graph) { | |
var lta=graph.attributeinfo.attributearray; | |
var nta=graph.nodeinfo.nodetypearray; | |
var legpadding=40; | |
var dlen=lta.length; | |
var ystart=(height/2)-(((dlen*100)+((dlen-1)*legpadding))/2); | |
var anylink = function() { | |
var curvature = .5; | |
// var iconht=(height-(legpadding*(dlen-1)))/dlen | |
// console.log('dlen',dlen); | |
function link(d,i) { | |
// console.log('d',d,'i',i); | |
var x0 = linklegwidth/2-50, | |
x1 = (linklegwidth/2-50)+100, | |
xi = d3.interpolateNumber(x0, x1), | |
x2 = xi(curvature), | |
x3 = xi(1 - curvature), | |
y0 = ystart+(i*(100+legpadding)), | |
y1 = ystart+(i*(100+legpadding))+100; | |
var svginst= | |
"M" + x0 + "," + y1 + " " | |
+ "C" + x2 + "," + y1 | |
+ " " + x3 + "," + y0 | |
+ " " + x1 + "," + y0; | |
// console.log('svginst',svginst) | |
return svginst; | |
} | |
link.curvature = function(_) { | |
if (!arguments.length) return curvature; | |
curvature = +_; | |
return link; | |
}; | |
return link; | |
}; | |
var anypath=anylink(); | |
sankey | |
.nodes(graph.nodes) | |
.links(graph.links) | |
.layout(32); | |
// add in the links | |
var link = svg.append("g").selectAll(".path") | |
.data(graph.links) | |
.enter().append("path") | |
.attr("class", "path") | |
.attr("d", path) | |
.style("stroke", function (d) { return linkcoldomain[lta.indexOf(d.attrib)]}) | |
.style("stroke-width", function(d) { return Math.max(1, d.dy); }) | |
.sort(function(a, b) { return b.dy - a.dy; }); | |
// add the link titles | |
link.append("title") | |
.text(function(d) { | |
return d.source.name + " → " + | |
d.target.name + "\n" + format(d.value); }); | |
// add in the nodes | |
var node = svg.append("g").selectAll(".node") | |
.data(graph.nodes) | |
.enter().append("g") | |
.attr("class", "node") | |
.attr("transform", function(d) { | |
return "translate(" + d.x + "," + d.y + ")"; }) | |
.call(d3.behavior.drag() | |
.origin(function(d) { return d; }) | |
.on("dragstart", function() { | |
this.parentNode.appendChild(this); }) | |
.on("drag", dragmove)); | |
// add the rectangles for the nodes | |
node.append("rect") | |
.attr("height", function(d) { return d.dy; }) | |
.attr("width", sankey.nodeWidth()) | |
.style("fill", function(d) { | |
var nodecol=""; | |
if (d.nodetype=="Hard Bounce") { | |
nodecol=bouncecolor; | |
} else {nodecol=nodecoldomain[nta.indexOf(d.nodetype)]; } | |
return nodecol; | |
}) | |
.style("stroke", function(d) { | |
return d3.rgb(d.color).darker(2); }) | |
.append("title") | |
.text(function(d) { | |
return d.name + "\n" + format(d.value); }); | |
// add in the title for the nodes | |
node.append("text") | |
.attr("x", -6) | |
.attr("y", function(d) { return d.dy / 2; }) | |
.attr("dy", ".35em") | |
.attr("text-anchor", "end") | |
.attr("transform", null) | |
.text(function(d) { return d.name; }); | |
//LEGEND SECTION | |
var leglink = legsvg.append("g").selectAll(".anypath") | |
.data(lta) | |
.enter().append("path") //this must be "path", because that's the d3 helper for accepting svg coordinates | |
.attr("d", anypath) | |
.attr("stroke", function (d) { return linkcoldomain[lta.indexOf(d)]}) | |
.attr("fill","none") | |
.attr("stroke-opacity", .2) | |
.attr("stroke-width", 40); | |
// add in the title for the nodes | |
legsvg.selectAll("text") | |
.data(lta) | |
.enter() | |
.append("text") | |
.attr("x",linklegwidth/2-20) | |
.attr("y",function(d,i) { | |
var y=ystart+(i*(100+legpadding))+50; | |
console.log('d',d,'i',i,"y",y); | |
return y; | |
}) | |
.text(function(d) {return d}); | |
// the function for moving the nodes | |
function dragmove(d) { | |
d3.select(this).attr("transform", | |
"translate(" + ( | |
d.x = Math.max(0, Math.min(width - d.dx, d3.event.x)) | |
) | |
+ "," + ( | |
d.y = Math.max(0, Math.min(height - d.dy, d3.event.y)) | |
) + ")"); | |
sankey.relayout(); | |
link.attr("d", link); | |
} | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is a great looking Sankey. Is there any way we can add % and weights to the Sankey diagram?