Skip to content

Instantly share code, notes, and snippets.

@mgoold
Last active February 29, 2016 16:33
Show Gist options
  • Save mgoold/95d3969755a9b2d35f09 to your computer and use it in GitHub Desktop.
Save mgoold/95d3969755a9b2d35f09 to your computer and use it in GitHub Desktop.
Site Visits Page Sequence Map Prototype

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 .

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;
};
{
"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"}
]}
<!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>
@akshayshenvi30
Copy link

This is a great looking Sankey. Is there any way we can add % and weights to the Sankey diagram?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment