Skip to content

Instantly share code, notes, and snippets.

@terrancesnyder
Last active May 26, 2020 08:07
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save terrancesnyder/227e02f3e2c8eef23f96 to your computer and use it in GitHub Desktop.
Save terrancesnyder/227e02f3e2c8eef23f96 to your computer and use it in GitHub Desktop.
Sankey Barchart with D3

Sankey Barchart

Info

Provides a visualization similar to google analytics shopper behavior analytics.

Requires

  • d3js
  • underscorejs
  • jquery

Usage

var options = { height: 150 };
var dataset = [
  { enter: { value: 19485, label: 'All Sessions' }, exit: { label: 'No Shopping Activity' } },
  { enter: { value: 5455, label: 'Sessions with Product Views' }, exit: { label: 'No Cart Addition' } },
  { enter: { value: 768, label: 'Sessions with Add to Cart' }, exit: { label: 'Cart Abandonment' } }
];
var bar = new SankeyBarchart('#mychart', dataset, options);
<!DOCTYPE html>
<meta charset="utf-8">
<link rel="stylesheet/less" type="text/css" href="sankey-barchart.less" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/less.js/2.5.1/less.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
<div id="chart" style="margin: 1em; height: 600px;"></div>
<script src="sankey-barchart.js"></script>
<script type="text/javascript">
var dataset = [
{
enter: { value: 20496, label: 'All Sessions' },
exit: { label: 'No Triggers' }
},
{
enter: { value: 11719, label: 'Trigger Min Order Amt' },
exit: { label: 'No Min Order Amt' }
},
{
enter: { value: 560, label: 'Trigger Min Qty' },
exit: { label: 'No Min Quantity' }
},
{
enter: { value: 6, label: 'Trigger Shopper Nbr' }
}
];
var chart = new SankeyBarchart('#chart', dataset);
</script>
/**
* Renders our sankey barchart, abstracted out
* so we can call multiple times to render while
* a person scales their browser/phone/etc.
*
* @class SankeyBarchat
* @constructor
* @param {String} domId The DOM ID selector to use to render into (ie: '#chart')
* @param {Mixed} dataset The dataset to render assumed to be in json array sequence with 'enter' and 'exit' variables.
* @param {Mixed} options Options parameters to control the chart.
*
* @example
* require(['sankey-barchart'], function(SankeyBarchart) {
* var options = { height: 150 };
* var dataset = [
* { enter: { value: 19485, label: 'All Sessions' }, exit: { label: 'No Shopping Activity' } },
* { enter: { value: 5455, label: 'Sessions with Product Views' }, exit: { label: 'No Cart Addition' } },
* { enter: { value: 768, label: 'Sessions with Add to Cart' }, exit: { label: 'Cart Abandonment' } }
* ];
* var bar = new SankeyBarchart('#mychart', dataset, options);
* });
*/
var SankeyBarchart = function(domId, dataset, options) {
// container for exposure to the outside world
var self = {};
// add our css tag so we can style canvas as we see fit
// specifically our headers and footers as well as our responsive design
// aspects
$(domId).addClass('sankey-barchart');
// when we resize the window we simply repaint, we wrap
// in underscores deferred and debounce methods to avoid
// eating windows CPU and paints
$(window).on('resize', _.debounce(function() {
_.defer(function() {
$(domId).html('');
self.render(domId, dataset, options);
});
}, 50));
// core rendering logic which is invoked as the window is resized
// or when the viz is first loaded
self.render = function(domId, dataset, options) {
// calculate abandonment rate per step to show the user in header/footer
_.each(_.range(dataset.length - 1), function(i) {
var exit_count = dataset[i].enter.value - dataset[i + 1].enter.value;
dataset[i].exit.value = exit_count;
dataset[i].exit.rate = exit_count / dataset[i].enter.value;
});
// calc aggregate conversion rate to show the user in header/footer
_.each(_.range(1, dataset.length), function(i) {
var rate = dataset[i].enter.value / dataset[0].enter.value;
dataset[i].enter.rate = rate;
});
// options the user can use to modify the presentation of the
// snakey barchat
options = _.defaults(options || {}, {
height: 200,
padding: {
top: 20,
right: 20
},
range: {
gap: 0.6,
padding: 0.5
},
axis: {
y: {
ticks: 4,
color: "#cccccc",
stroke: "#eeeeee",
format: ",.0f"
},
x: {
stroke: "#ddd",
dash_array: ("3, 3")
}
},
bar: {
stroke: "#6596EB",
fill: "#739FEE"
},
sankey: {
color: "#e9e9e9",
opacity: 0.4
}
});
// height and width, width is always assumed to be
// the width of the container to elastically and responsively
// scale to the device width and screen width
var w = $(domId).width();
var h = options.height || $(domId).height();
// ranges/gaps/padding along x
var xaxis_range_gap = d3.scale
.ordinal()
.domain(_.range(dataset.length)) // # of columns
.rangeRoundBands([0, w - options.padding.right], options.range.gap, options.range.padding); // range, gap, pad
// ===============================
// TABLE WIDTH - We want to render columns
// that match the columns in the SVG barchar so
// we can align our table headers and footers to
// these to give some KPIs and describe the data.
// ===============================
var table_column_width = function(d, i) {
var me = xaxis_range_gap(i).toFixed(0);
if (i < dataset.length - 1) {
var next = xaxis_range_gap(i + 1).toFixed(0);
return (next - me).toFixed(0) + 'px';
} else {
return (w - me).toFixed(0) + 'px';
}
};
// ===============================
// BARCHART HEADER
// ===============================
var svg = d3.select(domId)
.append("div")
.attr("class", "header")
.style("width", function() {
return w + 'px';
})
.style("position", "relative")
.style("display", "block")
.style("clear", "both")
.selectAll("span")
.data(dataset)
.enter()
.append("span")
.style("left", function(d, i) {
var x = xaxis_range_gap(i).toFixed(0);
return x + 'px';
})
.style("width", table_column_width)
.style("position", "absolute")
.html(function(d, i) {
return '<div class="th">' +
'<label>' + d.enter.label + '</label>' +
'<em class="value">' + d3.format(",.0f")(d.enter.value) + '</em>' +
(d.enter.rate ? '<em class="rate">' + d3.format(",.1%")(d.enter.rate) + '</em>' : '') +
'</div>';
});
// ===============================
// SVG
// ===============================
d3.select(domId)
.append("svg")
.attr("width", w)
.attr("height", h);
// ===============================
// BARCHART FOOTER
// ===============================
var svg = d3.select(domId)
.append("div")
.attr("class", "footer")
.style("width", function() {
return w + 'px';
})
.style("position", "relative")
.style("display", "block")
.style("clear", "both")
.selectAll("span")
.data(_.range(dataset.length - 1))
.enter()
.append("span")
.style("left", function(d, i) {
var x = xaxis_range_gap(i).toFixed(0);
return x + 'px';
})
.style("width", table_column_width)
.style("position", "absolute")
.html(function(d, i) {
var d = dataset[i];
return '<div class="th">' +
'<label>' + d.exit.label + '</label>' +
'<em class="value">' + d3.format(",.0f")(d.exit.value) + '</em>' +
(d.exit.rate ? '<em class="rate">' + d3.format(",.1%")(d.exit.rate) + '</em>' : '')
'</div>';
});
// ===============================
// XAXIS
// ===============================
var lines = d3.select("svg").append("g")
.attr("class", "x-lines")
.attr("transform", "translate(0,0)");
lines.selectAll("line.x")
.data(_.range(dataset.length))
.enter()
.append("line")
.attr("class", "x")
.attr("x1", function(d, i) {
return xaxis_range_gap(i);
})
.attr("y1", 0)
.attr("y2", h)
.attr("x2", function(d, i) {
return xaxis_range_gap(i);
})
.style("stroke", options.axis.x.stroke)
.style("stroke-dasharray", options.axis.x.dash_array);
// ===============================
// YAXIS
// ===============================
d3.select("svg").append("g")
.attr("class", "y-lines")
.attr("transform", "translate(0,0)");
var yaxis = d3.scale.ordinal()
.domain(_.range(options.axis.y.ticks))
.rangeBands([0, h], 0, 0);
d3.select("svg")
.select(".y-lines")
.selectAll("line.y")
.data(_.range(options.axis.y.ticks))
.enter()
.append("line")
.attr("class", "y")
.attr("x1", 0)
.attr("y1", function(d, i) {
return yaxis(i);
})
.attr("y2", function(d, i) {
return yaxis(i);
})
.attr("x2", w)
.style("stroke", options.axis.y.stroke);
d3.select("svg")
.append("g")
.attr("class", "y-text")
.attr("transform", "translate(0,0)");
var max_value = d3.max(dataset, function(d) {
return d.enter.value
});
var bar_height_fx = d3.scale.linear()
.domain([0, max_value])
.range([0, h])
.nice();
d3.select("svg")
.select(".y-text")
.selectAll("text")
.data(_.range(options.axis.y.ticks + 1))
.enter().append("text")
.attr("class", "y")
.attr("x", 2)
.attr("y", function(d, i) {
if (i == options.axis.y.ticks) {
return h - 3;
} else {
var y = yaxis(i) - 3;
return y;
}
})
.text(function(d, i) {
if (i == options.axis.y.ticks) {
return '0';
} else {
var frmt = d3.format(options.axis.y.format);
var y = h - yaxis(i);
var v = bar_height_fx.invert(y);
return frmt(Math.round(v));
}
})
.style("fill", options.axis.y.color)
// ===============================
// BARS
// ===============================
d3.select("svg")
.selectAll("rect")
.data(dataset)
.enter()
.append("rect")
.attr("x", function(d, i) {
return xaxis_range_gap(i);
})
.attr("y", function(d) {
return (h - bar_height_fx(d.enter.value));
})
.attr("width", xaxis_range_gap.rangeBand())
.attr("height", function(d) {
return bar_height_fx(d.enter.value)
})
.attr("fill", function(d) {
return options.bar.fill;
})
.style("stroke", options.bar.stroke);
// ===============================
// SANKEY FILL
// Drawing our connections between the bars to visualize
// the movement between the bars.
// ===============================
d3.select("svg")
.append("g")
.attr("class", "edges")
.attr("transform", "translate(0,0)");
d3.select("svg")
.select(".edges")
.selectAll(".edge")
.data(_.initial(dataset))
.enter()
.append("polygon")
.attr("class", "edge")
.attr("points", function(this_datapoint, i) {
var next_datapoint = dataset[i + 1];
var y1 = h - bar_height_fx(this_datapoint.enter.value);
var y2 = h - bar_height_fx(next_datapoint.enter.value);
var x1 = xaxis_range_gap(i);
var x2 = xaxis_range_gap(i + 1);
return [
x1 + xaxis_range_gap.rangeBand() + 1, y1, // top right of this bar
x2, y2, // top left of next bar
x2, h + 1, // bottom left of next bar
x1 + xaxis_range_gap.rangeBand() + 1, h + 1 // bottom right of this bar
].join(" ");
})
.style("fill", options.sankey.color)
.style("opacity", options.sankey.opacity);
};
// lets go ahead and render
self.render(domId, dataset, options);
return self;
};
.sankey-barchart {
overflow: hidden;
font: 12px 'Arial', sans-serif;
svg {
font: 12px 'Arial', sans-serif;
z-index: 99;
overflow: visible !important;
}
line {
shape-rendering: crispEdges;
}
rect {
shape-rendering: crispEdges;
}
div.footer,
div.header {
height: 61px;
border-top: 1px solid #E6E6E6;
&:after {
display: block;
clear: both;
content: " ";
float: none;
}
.th {
height: 45px;
font-weight: 300;
border-left: 1px solid #e6e6e6;
padding: 8px;
padding-top: 10px;
white-space: nowrap;
overflow: hidden;
line-height: 18px;
margin-left: -.25px;
position: relative;
label {
display: block;
margin: 0px;
padding: 0px;
font-weight: 200;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-indent: 1px;
text-overflow: ellipsis;
}
em.value {
font-style: normal;
font-weight: 500;
font-size: 1.5em;
display: block;
margin: 0px;
padding: 0px;
white-space: nowrap;
overflow: hidden;
margin-top: 1px;
text-overflow: ellipsis;
}
em.rate {
position: absolute;
bottom: 1em;
right: 1em;
font-style: normal;
font-weight: 300;
color: #c0c0c0;
text-decoration: none;
font-size: 12px;
text-overflow: ellipsis;
}
}
}
div.footer {
margin-top: -3px;
height: 107px;
z-index: 1;
em.rate {
color: #E98172;
}
em.rate:before {
content: "-";
}
span {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAZCAMAAAAR624oAAAADFBMVEXibl7to5r1ycL////tmwwaAAAARklEQVR4Ae3JwQmAAAwEsOrtv7NChVAqTmC+qTxqSnvvv8/6cFT221T227vX2+75tnu+1d5qb7W32lvt92pvtbfaW+3n5gLgFQRmWCp2+wAAAABJRU5ErkJggg==);
background-repeat: no-repeat;
background-position: 15px 10px;
padding-top: 45px;
border-left: 1px dashed #e6e6e6;
&:last-child {
border-right: 1px dashed #e6e6e6;
.th {
border-right: 1px solid #e6e6e6;
margin-right: -1px;
}
}
}
.th {
border-bottom: 1px solid #e6e6e6;
border-top: 1px solid #e6e6e6;
margin-left: -1px;
}
}
}
@media screen and ( max-width: 760px) {
.sankey-barchart {
.rate {
bottom: .60em !important;
font-size: 11px !important;
}
}
}
@media screen and ( max-width: 640px) {
.sankey-barchart {
.rate {
display: none !important;
}
}
}
@media screen and ( max-width: 760px) {
.sankey-barchart {
.th {
padding: 5px !important;
label {
font-size: 11px !important;
margin: 0px !important;
line-height: 12px !important;
}
em.value {
font-size: 16px !important;
font-weight: bold !important;
margin-top: 2px !important;
padding-top: 0px !important;
}
}
.footer {
height: 90px !important;
span {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAANCAMAAABBwMRzAAAADFBMVEXibl7to5r1ycL////tmwwaAAAALUlEQVR42qXLIRIAMAzDsOz0/z8PBHTFMxNwIA2+vTuxKDzsP6wNa+VYOVZyAWu0ASkIjv27AAAAAElFTkSuQmCC) !important;
padding-top: 30px !important;
background-position: 5px 10px !important;
}
.th {
height: 45px !important;
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment