Skip to content

Instantly share code, notes, and snippets.

@mwhitaker mwhitaker/README.md
Last active Feb 3, 2016

Embed
What would you like to do?
Multi-channel sunbursts

D3 Sunburst visualization that uses the Embed API to pull data from the Google Analytics Multi-Channel Funnel API. This app runs purely in the browser and no data is sent to any server. A bit more info is on my blog.

Any suggestions for improvement would be welome!

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Sequences sunburst of Google Analytics multi-channel funnel report</title>
<script src="http://d3js.org/d3.v3.min.js"></script>
<link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css?family=Open+Sans:400,600">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" integrity="sha384-1q8mTJOASx8j1Au+a5WDVnPi2lkFfwwEAa8hDDdjZlpLegxhjVME1fgjWPGmkzs7" crossorigin="anonymous">
<style>
body {
font-family: "Open Sans", sans-serif;
font-size: 12px;
font-weight: 400;
padding-top: 10px;
padding-bottom: 100px;
}
.percentage {
font-size: 2em;
}
#signout {display: none;}
#bread {
float: left;
width: 100px;
}
}
.top-buffer { margin-bottom:50px; }
</style>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.0/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js" integrity="sha384-0mSbJDEHialfmuBBQP6A4Qrprq5OVfW37PRR3j5ELqxss1yVqOtnepnHVP9aJ7xS" crossorigin="anonymous"></script>
</head>
<body>
<script>
(function(w,d,s,g,js,fs){
g=w.gapi||(w.gapi={});g.analytics={q:[],ready:function(f){this.q.push(f);}};
js=d.createElement(s);fs=d.getElementsByTagName(s)[0];
js.src='https://apis.google.com/js/platform.js';
fs.parentNode.insertBefore(js,fs);js.onload=function(){g.load('analytics');};
}(window,document,'script'));
</script>
<div class="container-fluid">
<nav class="navbar navbar-inverse">
<div class="container-fluid">
<div class="navbar-header">
<div class="navbar-brand">Google Analytics D3 Sunburst visualization for Multi-channel funnel data</div>
</div>
</div>
</nav>
<div class="row">
<div class="col-xs-12 col-sm-6 col-lg-6">
<div id="auth-message" class="alert">This app runs only in your browser and no data is sent to any server. For some reason the Google Analytics Embed API requests access to the basic user profile info, but this data is not used or sent anywhere, nor would I be able to know who is using it!</div>
<div id="embed-api-auth-container"></div>
<div id="signout"><button class="btn btn-primary" type="button">Signout</button></div>
</div>
<div class="col-xs-12 col-sm-6 col-lg-6">
<div id="view-selector-container"></div>
</div>
</div>
<hr>
<div class="row">
<div class="col-xs-1"><div id="bread"></div></div>
<div class="col-xs-6"><div id="chart"></div></div>
<div class="col-xs-2"><div id="leg"></div></div>
</div>
<div class="row">
<div class="col-xs-12"><div id="creds"></div><a href="https://gist.github.com/mwhitaker/0b268d04e00df7bc5286">Code</a>. Inspired by <a href="http://bl.ocks.org/chrisrzhou/raw/d5bdd8546f64ca0e4366/">Chris Zhou</a> and <a href="http://bl.ocks.org/kerryrodden/7090426">Kerry Rodden</a></div>
</div>
</div>
<script type="text/javascript">
gapi.analytics.ready(function() {
/**
* Authorize the user immediately if the user has already granted access.
* If no access has been created, render an authorize button inside the
* element with the ID "embed-api-auth-container".
*/
var CLIENT_ID = '886561330860-85q1vheq9tdni9joc79v8k1rtgqvpttn.apps.googleusercontent.com';
gapi.analytics.auth.authorize({
container: 'embed-api-auth-container',
clientid: CLIENT_ID
});
gapi.analytics.auth.on('signIn', function() {
$("#signout").toggle();
$("#auth-message").hide();
$("#signout").click(function() {
gapi.auth2.getAuthInstance().disconnect();
});
});
gapi.analytics.auth.on('signOut', function() {
$("#signout").toggle();
});
/**
* Create a new ViewSelector instance to be rendered inside of an
* element with the id "view-selector-container".
*/
var viewSelector = new gapi.analytics.ViewSelector({
container: 'view-selector-container'
});
// Render the view selector to the page.
viewSelector.execute();
/**
* Create a new DataChart instance with the given query parameters
* and Google chart options. It will be rendered inside an element
* with the id "chart-container".
/**
* Render the dataChart on the page whenever a new view is selected.
*/
viewSelector.on('change', function(ids) {
mcf(ids);
});
});
// Dimensions of sunburst.
var width = 500;
var height = 500;
var radius = Math.min(width, height) / 2;
// Breadcrumb dimensions: width, height, spacing, width of tip/tail.
var b = {
w: 80,
h: 30,
s: 3,
t: 10
};
// Legend dimensions: width, height, spacing, radius of rounded rect.
var li = {
w: 95,
h: 30,
s: 3,
r: 3
};
// margins
var margin = {
top: radius,
bottom: 50,
left: radius,
right: 0
};
// sunburst margins
var sunburstMargin = {
top: 2 * radius + b.h,
bottom: 0,
left: 0,
right: radius / 2
};
/**
* Drawing variables:
*
* e.g. colors, totalSize, partitions, arcs
*/
// Mapping of nodes to colorscale.
var colors = {
"Paid Search": "#1e6bff",
"Organic Search": "#639c24",
"Email": "#ce0005",
"Direct": "#ab8b00",
"Referral": "#864c62",
"Other Advertising": "#ee6900",
"Display": "#5a6986",
"Social Network": "#5323a6",
"(unavailable)": "#ccc",
"Convert": "#bbbbbb"
};
// var colors = d3.scale.category10();
// Total size of all segments; we set this later, after loading the data.
var totalSize = 0;
var vis = d3.select("#chart")
.append("div").classed("vis-container", true)
.style("position", "relative")
.style("margin-top", "20px")
.style("margin-bottom", "20px")
.style("left", "50px")
.style("height", height + 2 * b.h + "px");
// create and position SVG
var sunburst = vis
.append("div").classed("sunburst-container", true)
.style("position", "absolute")
.style("left", sunburstMargin.left + "px")
.append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
// create and position legend
var legend = d3.select("#leg")
.append("div").classed("legend-container", true)
//.style("position", "absolute")
.style("top", b.h + "px")
.style("left", 2 * radius + sunburstMargin.right + "px")
.style("width", 50 + "px")
.style("height", 50 + "px")
.append("svg")
.attr("width", li.w)
.attr("height", height);
// create and position breadcrumbs container and svg
var breadcrumbs = d3.select("#bread")
.append("div").classed("breadcrumbs-container", true)
//.style("position", "absolute")
//.style("top", sunburstMargin.top + "px")
.append("svg")
.attr("width", b.w)
.attr("height", 300)
.attr("fill", "white")
.attr("font-weight", 600);
// create last breadcrumb element
var lastCrumb = breadcrumbs
.append("text").classed("lastCrumb", true);
// create and position summary container
var summary = vis
.append("div").classed("summary-container", true)
.style("position", "absolute")
.style("top", radius * 0.80 + "px")
.style("left", sunburstMargin.left + radius / 2 + "px")
.style("width", radius + "px")
.style("height", radius + "px")
.style("text-align", "center")
.style("font-size", "16px")
.style("color", "#666")
.style("z-index", "-1");
var partition = d3.layout.partition()
.size([2 * Math.PI, radius * radius])
.value(function(d) { return d.size; });
var arc = d3.svg.arc()
.startAngle(function(d) { return d.x; })
.endAngle(function(d) { return d.x + d.dx; })
.innerRadius(function(d) { return Math.sqrt(d.y); })
.outerRadius(function(d) { return Math.sqrt(d.y + d.dy); });
function mcf(ids) {
gapi.client.analytics.data.mcf.get({
ids: ids,
metrics: "mcf:totalConversions",
dimensions: "mcf:basicChannelGroupingPath",
"start-date": "30daysAgo",
"end-date": "yesterday",
sort: "-mcf:totalConversions",
"max-results": 100
})
.then(function(response) {
var resp = response.result;
if(resp.rows) {
//var hl = $("#test");
//hl.html(JSON.stringify(resp.rows));
var json = buildHierarchy(resp.rows);
removeVisualization(); // remove existing visualization if any
createVisualization(json);
// data.rows.push(resp.rows);
}
})
.then(null, function(err) {
// Log any errors.
console.log(err);
});
}
function removeVisualization() {
sunburst.selectAll(".nodePath").remove();
legend.selectAll("g").remove();
}
function createVisualization(json) {
drawSunburst(json); // draw sunburst
drawLegend(); // draw legend
};
function colorMap(d) {
return colors[d.name]; //colors(d.name);
}
function drawSunburst(json) {
// Build only nodes of a threshold "visible" sizes to improve efficiency
var nodes = partition.nodes(json)
.filter(function(d) {
return (d.dx > 0.005); // 0.005 radians = 0.29 degrees
});
// this section is required to update the colors.domain() every time the data updates
// var uniqueNames = (function(a) {
// var output = [];
// a.forEach(function(d) {
// if (output.indexOf(d.name) === -1) output.push(d.name);
// });
// return output;
// })(nodes);
// colors.domain(uniqueNames); // update domain colors
// create path based on nodes
var path = sunburst.data([json]).selectAll("path")
.data(nodes).enter()
.append("path").classed("nodePath", true)
.attr("display", function(d) {
return d.depth ? null : "none";
})
.attr("d", arc)
.attr("fill", colorMap)
.attr("opacity", 1)
.attr("stroke", "white")
.on("mouseover", mouseover);
// // trigger mouse click over sunburst to reset visualization summary
vis.on("click", click);
// Update totalSize of the tree = value of root node from partition.
totalSize = path.node().__data__.value;
}
function drawLegend() {
// remove "root" label from legend
// var labels = colors.domain().splice(1, colors.domain().length);
// create legend "pills"
var g = legend.selectAll("g")
.data(d3.entries(colors)).enter() //(labels)
.append("g")
.attr("transform", function(d, i) {
return "translate(0," + i * (li.h + li.s) + ")";
});
g.append("rect").classed("legend-pills", true)
.attr("rx", li.r)
.attr("ry", li.r)
.attr("width", li.w)
.attr("height", li.h)
.style("fill", function(d) {
return d.value; //colors(d)
});
g.append("text").classed("legend-text", true)
.attr("x", li.w / 2)
.attr("y", li.h / 2)
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("fill", "white")
.attr("font-size", "10px")
.attr("font-weight", 600)
.text(function(d) {
return d.key;
});
}
function mouseover(d) {
// build percentage string
var percentage = (100 * d.value / totalSize).toPrecision(3);
var percentageString = percentage + "%";
if (percentage < 1) {
percentageString = "< 1.0%";
}
// update breadcrumbs (get all ancestors)
var ancestors = getAncestors(d);
updateBreadcrumbs(ancestors, percentageString);
// update sunburst (Fade all the segments and highlight only ancestors of current segment)
sunburst.selectAll("path")
.attr("opacity", 0.3);
sunburst.selectAll("path")
.filter(function(node) {
return (ancestors.indexOf(node) >= 0);
})
.attr("opacity", 1);
// update summary
summary.html(
"Interaction: " + d.depth + "<br />" +
"<span class='percentage'>" + percentageString + "</span><br />" +
d.value + " of " + totalSize + "<br /> Conversions"
);
// display summary and breadcrumbs if hidden
summary.style("visibility", "");
breadcrumbs.style("visibility", "");
}
// helper function click to handle mouseleave events/animations
function click(d) {
// Deactivate all segments then retransition each segment to full opacity.
sunburst.selectAll("path").on("mouseover", null);
sunburst.selectAll("path")
.transition()
.duration(1000)
.attr("opacity", 1)
.each("end", function() {
d3.select(this).on("mouseover", mouseover);
});
// hide summary and breadcrumbs if visible
breadcrumbs.style("visibility", "hidden");
summary.style("visibility", "hidden");
}
// Return array of ancestors of nodes, highest first, but excluding the root.
function getAncestors(node) {
var path = [];
var current = node;
while (current.parent) {
path.unshift(current);
current = current.parent;
}
return path;
}
// Generate a string representation for drawing a breadcrumb polygon.
function breadcrumbPoints(d, i) {
var points = [];
points.push("0,0");
if (i>0) {
points.push((b.w/3) + ",0");
points.push((b.w/2) + "," + b.t);
points.push((2 * b.w / 3) + ",0");
}
points.push(b.w + ",0");
points.push(b.w + "," + b.h);
points.push((2* b.w / 3) + "," + b.h);
points.push((b.w/2) + "," + (b.t + b.h));
points.push((b.w/3) + "," + b.h);
points.push("0," + b.h);
return points.join(" ");
}
/*function breadcrumbPoints_old(d, i) {
var points = [];
points.push("0,0");
points.push(b.w + ",0");
points.push(b.w + b.t + "," + (b.h / 2));
points.push(b.w + "," + b.h);
points.push("0," + b.h);
if (i > 0) { // Leftmost breadcrumb; don't include 6th vertex.
points.push(b.t + "," + (b.h / 2));
}
return points.join(" ");
}
*/
// Update the breadcrumb breadcrumbs to show the current sequence and percentage.
function updateBreadcrumbs(ancestors, percentageString) {
// Data join, where primary key = name + depth.
var g = breadcrumbs.selectAll("g")
.data(ancestors, function(d) {
return d.name + d.depth;
});
// Add breadcrumb and label for entering nodes.
var breadcrumb = g.enter().append("g");
breadcrumb
.append("polygon").classed("breadcrumbs-shape", true)
.attr("points", breadcrumbPoints)
.attr("fill", colorMap);
breadcrumb
.append("text").classed("breadcrumbs-text", true)
.attr("y", (b.h + b.t) / 2)
.attr("x", b.w / 2)
.attr("dy", "0.35em")
.attr("font-size", "10px")
.attr("text-anchor", "middle")
.text(function(d) {
return d.name;
});
// Set position for entering and updating nodes.
g.attr("transform", function(d, i) {
//return "translate(" + i * (b.w + b.s) + ", 0)";
return "translate(0, " + i * (b.h + b.s) + ")";
});
// Remove exiting nodes.
g.exit().remove();
// Update percentage at the lastCrumb.
lastCrumb
.attr("x", b.w / 2)
.attr("y", (ancestors.length + 0.5) * (b.h + b.s))
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("fill", "black")
.attr("font-weight", 600)
.text(percentageString);
}
function buildHierarchy(res) {
var root = {"name": "root", "children": []};
for (var i = 0; i < res.length; i++) {
var size = res[i][1]["primitiveValue"]
var lengthy = res[i][0]["conversionPathValue"].length
res[i][0]["conversionPathValue"].push({nodeValue: "Convert"});
var lengthy = res[i][0]["conversionPathValue"].length
if (lengthy > 7) {lengthy = 7}
var currentNode = root;
//console.log(lengthy)
for (var j = 0; j < lengthy; j++) {
var children = currentNode["children"];
var nodeName = res[i][0]["conversionPathValue"][j]["nodeValue"];
var childNode;
if (j + 1 < lengthy) {
// Not yet at the end of the sequence; move down the tree.
var foundChild = false;
for (var k = 0; k < children.length; k++) {
if (children[k]["name"] == nodeName) {
childNode = children[k];
foundChild = true;
break;
}
}
// If we don't already have a child node for this branch, create it.
if (!foundChild) {
childNode = {"name": nodeName, "children": []};
children.push(childNode);
}
currentNode = childNode;
} else {
// Reached the end of the sequence; create a leaf node.
childNode = {"name": nodeName, "size": size};
children.push(childNode);
}
}
}
return root;
};
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.