|
function sunburstDraw(scope, element) { |
|
|
|
/** |
|
* Angular variables |
|
* |
|
*/ |
|
// watch for changes on scope.data |
|
scope.$watch("data", function() { |
|
var data = scope.data; |
|
render(data); |
|
}); |
|
|
|
|
|
/** |
|
* Dimensions of svg, sunburst, legend, breadcrumbs |
|
* |
|
*/ |
|
// svg dimensions |
|
var width = 500; |
|
var height = 300; |
|
var radius = Math.min(width, height) / 2; |
|
|
|
// Breadcrumb dimensions: width, height, spacing, width of tip/tail. |
|
var b = { |
|
w: 60, |
|
h: 30, |
|
s: 3, |
|
t: 10 |
|
}; |
|
|
|
// Legend dimensions: width, height, spacing, radius of rounded rect. |
|
var li = { |
|
w: 75, |
|
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 = d3.scale.category10(); |
|
|
|
// Total size of all nodes, to be used later when data is loaded |
|
var totalSize = 0; |
|
|
|
// create d3.layout.partition |
|
var partition = d3.layout.partition() |
|
.size([2 * Math.PI, radius * radius]) |
|
.value(function(d) { |
|
return d.size; |
|
}); |
|
|
|
// create arcs for drawing D3 paths |
|
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); |
|
}); |
|
|
|
|
|
|
|
|
|
/** |
|
* Define and initialize D3 select references and div-containers |
|
* |
|
* e.g. vis, breadcrumbs, lastCrumb, summary, sunburst, legend |
|
*/ |
|
// create main vis selection |
|
var vis = d3.select(element[0]) |
|
.append("div").classed("vis-continer", 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 = vis |
|
.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 = vis |
|
.append("div").classed("breadcrumbs-container", true) |
|
.style("position", "absolute") |
|
.style("top", sunburstMargin.top + "px") |
|
.append("svg") |
|
.attr("width", width) |
|
.attr("height", b.h) |
|
.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", "11px") |
|
.style("color", "#666") |
|
.style("z-index", "-1"); |
|
|
|
|
|
|
|
/** |
|
* Render process: |
|
* |
|
* 1) Load data |
|
* 2) Build Tree |
|
* 3) Draw visualization |
|
*/ |
|
// render visualization |
|
function render(data) { |
|
var parsedData = d3.csv.parseRows(data); // load data |
|
var json = buildHierarchy(parsedData); // build json tree |
|
removeVisualization(); // remove existing visualization if any |
|
createVisualization(json); // visualize json tree |
|
} |
|
|
|
|
|
|
|
/** |
|
* Helper functions: |
|
* |
|
* @function removeVisualization(): removes existing SVG components |
|
* @function createVisualization(json): create visualization from json tree structure |
|
* @function colorMap(d): color nodes with colors mapping |
|
* @function mouseover(d): mouseover function |
|
* @function mouseleave(d): mouseleave function |
|
* @function getAncestors(node): get ancestors of a specified node |
|
* @function buildHierarchy(data): generate json nested structure from csv data input |
|
*/ |
|
// removes existing SVG components |
|
function removeVisualization() { |
|
sunburst.selectAll(".nodePath").remove(); |
|
legend.selectAll("g").remove(); |
|
} |
|
|
|
|
|
// visualize json tree structure |
|
function createVisualization(json) { |
|
drawSunburst(json); // draw sunburst |
|
drawLegend(); // draw legend |
|
}; |
|
|
|
|
|
// helper function colorMap - color gray if "end" is detected |
|
function colorMap(d) { |
|
return colors(d.name); |
|
} |
|
|
|
|
|
// helper function to draw the sunburst and breadcrumbs |
|
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; |
|
} |
|
|
|
|
|
// helper function to draw legend |
|
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(labels).enter() |
|
.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 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; |
|
}); |
|
} |
|
|
|
|
|
// helper function mouseover to handle mouseover events/animations and calculation of ancestor nodes etc |
|
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( |
|
"Stage: " + d.depth + "<br />" + |
|
"<span class='percentage'>" + percentageString + "</span><br />" + |
|
d.value + " of " + totalSize + "<br />" |
|
); |
|
|
|
// 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"); |
|
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("x", (b.w + b.t) / 2) |
|
.attr("y", b.h / 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)"; |
|
}); |
|
|
|
// Remove exiting nodes. |
|
g.exit().remove(); |
|
|
|
// Update percentage at the lastCrumb. |
|
lastCrumb |
|
.attr("x", (ancestors.length + 0.5) * (b.w + b.s)) |
|
.attr("y", b.h / 2) |
|
.attr("dy", "0.35em") |
|
.attr("text-anchor", "middle") |
|
.attr("fill", "black") |
|
.attr("font-weight", 600) |
|
.text(percentageString); |
|
} |
|
|
|
|
|
|
|
// Take a 4-column CSV of ["sequence", "stage", "node", "value"] and |
|
// transform it into a hierarchical structure suitable for a partition layout. |
|
function buildHierarchy(csv) { |
|
var data = csv2json(csv); // build JSON dataframe from csv using helper function |
|
|
|
// build tree |
|
var root = { |
|
name: "root", |
|
children: [] |
|
}; |
|
|
|
data.forEach(function(d) { |
|
var nodes = d.nodes; |
|
var size = parseInt(d.size); |
|
|
|
// build graph, nodes, and child nodes |
|
var currentNode = root; |
|
for (var j = 0; j < nodes.length; j++) { |
|
var children = currentNode.children; |
|
var nodeName = nodes[j]; |
|
var childNode; |
|
|
|
if (j + 1 < nodes.length) { |
|
// 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 (!foundChild) { // If we don't already have a child node for this branch, create it. |
|
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; |
|
} |
|
|
|
|
|
|
|
// helper function to buildHierarchy to transform 4-column CSV into a JSON dataframe. |
|
function csv2json(csv) { |
|
var data = []; |
|
var sequences = []; |
|
|
|
// sort the dataframe ascending by sequence (d[0]) then by stage (d[1]) |
|
csv.sort(function(a, b) { |
|
if (a[2] === b[2]) { |
|
return d3.ascending(a[0], b[0]); |
|
} |
|
return d3.ascending(a[1], b[1]); |
|
}); |
|
csv.forEach(function(record) { |
|
var sequence = record[0]; |
|
if (sequences.indexOf(sequence) < 0) sequences.push(sequence); |
|
}); |
|
|
|
sequences.forEach(function(sequence) { |
|
var d = { |
|
nodes: [], |
|
size: 0 |
|
}; |
|
csv.forEach(function(record) { |
|
var node = record[2]; |
|
var size = record[3]; |
|
if (sequence === record[0]) { |
|
d.nodes.push(node); |
|
d.size = size; |
|
} |
|
}); |
|
data.push(d); |
|
}); |
|
return data; |
|
} |
|
} |
Hi there,
Great write up Chris. I'm thankful for it!
I noticed that there's a console error every time I load the page. Looking closer at the code, it appears to have something to do with Angular and d3.csv.parse. Specifically, line 161 in sunburst.js is the root of the error. Do you happen to have any clues as to what's causing this?
Thanks!