|
|
|
// ***** MAIN CODE ***** |
|
|
|
// Basic dimensions |
|
var width = 750; |
|
var height = 600; |
|
var radius = Math.min(width, height) / 2; |
|
var radius2 = radius * radius; |
|
|
|
// Stashed dictionaries of values |
|
var colors = {}; |
|
var jsonAlarms = {}; |
|
var jsonFiles = {}; |
|
|
|
// Cached selected and status values |
|
var filesel; |
|
var timesel; |
|
var lastFileViewed; |
|
var action = 1; // 1 = we are on the first load, 0 = chart is already loaded and we are only updating |
|
var lockStatus = 0; // 1 = view is locked on a specific level, 0 = view is not locked and it is possible to mouseover and zoom |
|
var dblclickDuration = 600; |
|
var updating = 0; // 1 = in the middle of updating a transition so disable mouseover, 0 = no transition ongoing |
|
var tweenActive = 0; |
|
|
|
// Get x and y for zooming purposes |
|
var x = d3.scaleLinear() |
|
.range([0, 2 * Math.PI]); |
|
|
|
var y = d3.scaleSqrt() |
|
.range([0, radius]); |
|
|
|
// Breadcrumb dimensions: width, height, spacing, width of tip/tail. |
|
var b = { |
|
w: width, h: 30, s: 3, t: 8 |
|
}; |
|
|
|
// Dimensions of legend item: width, height, spacing, radius of rounded rect. |
|
var li = { |
|
w: 500, h: 24, s: 3, r: 3 |
|
}; |
|
|
|
// Save delimiter as variable |
|
var delim = "::"; |
|
|
|
// Total size of all segments; we set this later, after loading the data. |
|
var totalSize = 0; |
|
|
|
var vis = d3.select("#chart").append("svg:svg") |
|
.attr("width", width) |
|
.attr("height", height) |
|
.append("svg:g") |
|
.attr("id", "container") |
|
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")"); |
|
|
|
var partition = d3.partition(); |
|
|
|
var arc = d3.arc() |
|
.startAngle(function (d) { return Math.max(0, Math.min(2 * Math.PI, x(d.x0))); }) |
|
.endAngle(function (d) { return Math.max(0, Math.min(2 * Math.PI, x(d.x1))); }) |
|
.innerRadius(function (d) { return Math.max(0, y(d.y0)); }) |
|
.outerRadius(function (d) { return Math.max(0, y(d.y1)); }); |
|
|
|
filesel = $("#fileSelect"); |
|
|
|
loadData(); |
|
|
|
// ***** MAIN CODE END ***** |
|
|
|
|
|
// Read data from disk and create sunburst |
|
function loadData() { |
|
if (updating == 0) { |
|
var csvFileName = filesel[0].value; |
|
|
|
d3.text(csvFileName, function(text) { |
|
var csv = d3.csvParseRows(text); |
|
var json = buildHierarchy(csv); |
|
loadSunburst(json); |
|
}); |
|
} |
|
} |
|
|
|
// Load the sunburst |
|
function loadSunburst(json) { |
|
// Get unique alarms and create colours for them |
|
var uniqueKeys = findUniqueKeys(json["children"], "name"); |
|
generateColours(uniqueKeys); |
|
|
|
if (action == 1) { |
|
createVisualization(json); // Load everything for first time |
|
drawLegend(); |
|
} else { |
|
totalSize = updateVisualisation(json); |
|
updateLegend(); |
|
} |
|
|
|
d3.select("#totalCount").text(totalSize); |
|
} |
|
|
|
// Function to randomise array, from http://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array |
|
function shuffle(array) { |
|
var currentIndex = array.length, temporaryValue, randomIndex; |
|
|
|
// While there remain elements to shuffle... |
|
while (0 !== currentIndex) { |
|
|
|
// Pick a remaining element... |
|
randomIndex = Math.floor(Math.random() * currentIndex); |
|
currentIndex -= 1; |
|
|
|
// And swap it with the current element. |
|
temporaryValue = array[currentIndex]; |
|
array[currentIndex] = array[randomIndex]; |
|
array[randomIndex] = temporaryValue; |
|
} |
|
|
|
return array; |
|
} |
|
|
|
// Function to get unique keys so we can assign them an appropriate colour |
|
function findUniqueKeys(json, attr) { |
|
var allKeys = []; |
|
var uniqueKeys = []; |
|
var wholeString = ""; |
|
|
|
if (typeof(json) != "undefined") { |
|
for (var i = 0; i < json.length; i++) { |
|
allKeys.push(json[i][attr]); // Push current attribute into array |
|
|
|
var subspace = json[i]["children"]; // Get child elements |
|
|
|
var newKeys = findUniqueKeys(subspace, attr); |
|
|
|
allKeys = allKeys.concat(newKeys); // Recursively call function until leaf node reached |
|
} |
|
|
|
allKeys.sort(); |
|
|
|
for (var i = 0; i < allKeys.length - 1; i++) { |
|
if (allKeys[i + 1] != allKeys[i]) { // If current element does not equal next element then add it |
|
uniqueKeys.push(allKeys[i]); |
|
} |
|
} |
|
|
|
uniqueKeys.push(allKeys[allKeys.length - 1]); // Last element doesn't have next element to distinguish it, so always add last |
|
} |
|
|
|
return uniqueKeys; |
|
} |
|
|
|
// Create colour palette |
|
function generateColours(uniqueKeys) { |
|
var hueStep = Math.max(Math.floor(360 / uniqueKeys.length), 1); |
|
var hexStrings = []; |
|
|
|
for (k in colors) { // Deactivate all colours for legend plotting |
|
colors[k].active = 0; |
|
} |
|
|
|
for (var i = 0; i < uniqueKeys.length; i++) { |
|
var H = (i * hueStep) % 360; |
|
var S = Math.floor(Math.random() * 50) + 30; // Gives range between 30 and 80 |
|
var L = Math.floor(Math.random() * 45) + 35; // Gives range between 35 and 80 |
|
|
|
var hslValue = "hsl(" + H + ", " + S + "%, " + L + "%)"; |
|
|
|
var hslValueTC = tinycolor(hslValue); |
|
|
|
hexStrings.push(hslValueTC.toHexString()); |
|
} |
|
|
|
hexStrings = shuffle(hexStrings); // Randomise array for assignment |
|
|
|
for (var i = 0; i < uniqueKeys.length; i++) { |
|
if (!(uniqueKeys[i] in colors)) { // Only update colours array if we haven't seen this alarm before |
|
colors[uniqueKeys[i]] = { "fill": hexStrings[i], "active": 0 }; |
|
} |
|
|
|
colors[uniqueKeys[i]].active = 1; |
|
} |
|
|
|
colors["end"] = "#000000" |
|
} |
|
|
|
// Main function to draw and set up the visualization, once we have the data. |
|
function createVisualization(json) { |
|
|
|
// Basic setup of page elements. |
|
initializeBreadcrumbTrail(); |
|
d3.select("#togglelegend").on("click", toggleLegend); |
|
|
|
// Bounding circle underneath the sunburst, to make it easier to detect |
|
// when the mouse leaves the parent g. |
|
vis.append("svg:circle") |
|
.attr("r", radius) |
|
.attr("id", "boundingCircle") |
|
.style("opacity", 0); |
|
|
|
// Get total size of the tree = value of root node from partition. |
|
totalSize = updateVisualisation(json); |
|
|
|
// Add the mouseleave handler to the bounding circle. |
|
d3.select("#container").on("mouseleave", mouseleave); |
|
}; |
|
|
|
// Function for updating chart when a new dataset is chosen |
|
function updateVisualisation(json) { |
|
// Emulate double click event on root node so that new chart will start zoomed all the way out |
|
if (tweenActive == 1) { |
|
d3.select("#rootNode").dispatch("dblclick"); |
|
} |
|
|
|
// For efficiency, filter nodes to keep only those large enough to see. |
|
var h = d3.hierarchy(json); |
|
h |
|
.sum(function (d) { return d.size; }) // Determine how to list value of children |
|
.sort(function (a, b) { return b.value - a.value; }) // Descending order sorting |
|
var nodes = partition(h).descendants(); |
|
|
|
// Wait for the double click duration in case an animation is ongoing |
|
setTimeout(function () { |
|
updating = 1; |
|
|
|
if (action == 1) { |
|
createPaths(nodes); |
|
} else { |
|
updatePaths(nodes); |
|
} |
|
|
|
vis.selectAll("path") |
|
.on("mouseover", mouseover) |
|
.on("click", click) |
|
.on("dblclick", dblclick); |
|
|
|
updating = 0; |
|
|
|
// We've loaded for the first time so reset action to 0 |
|
action = 0; |
|
} |
|
, dblclickDuration + 10, nodes); |
|
|
|
return h.value; |
|
} |
|
|
|
// Function to create path elements |
|
function createPaths(nodes) { |
|
var path = vis.selectAll("path") |
|
.data(nodes) |
|
|
|
path.enter().append("svg:path") // This adds new path elements to sunburst for new data points |
|
.merge(path) // Merges back with original data |
|
.transition().duration(250) |
|
.attr("d", arc) |
|
.attr("fill-rule", "evenodd") |
|
.attr("id", function (d) { return d.depth ? null : "rootNode" }) |
|
.style("fill", function (d) { return d.depth ? colors[d.data.name].fill : "white"; }) |
|
.style("opacity", 1) |
|
.each(function(d) { |
|
this.__data__._current = d; |
|
}); // Temporarily save the current values of d for transitioning later |
|
|
|
path.exit().remove(); // This removes unused data points from the chart |
|
} |
|
|
|
// Function to update chart elements |
|
function updatePaths(nodes) { |
|
var path = vis.selectAll("path") |
|
.data(nodes) |
|
|
|
path.enter().append("svg:path") // This adds new path elements to sunburst for new data points |
|
.merge(path) // Merges back with original data |
|
.each(function(d) { |
|
this.__data__._current = d; |
|
}) // Temporarily save the current values of d for transitioning later |
|
.transition().duration(250) |
|
.attrTween("d", arcTween) |
|
.attr("fill-rule", "evenodd") |
|
.attr("id", function (d) { return d.depth ? null : "rootNode" }) |
|
.style("fill", function (d) { return d.depth ? colors[d.data.name].fill : "white"; }) |
|
.style("opacity", 1); |
|
|
|
path.exit().remove(); // This removes unused data points from the chart |
|
} |
|
|
|
// Function to calculate the tween for transitions |
|
function arcTween(d) { |
|
// Need to create custom dictionaries as there are arrays with children in __data__, d3 will try and interpolate these and blow the stack! |
|
var old_vals = {'x0': this.__data__._current.x0, |
|
'x1': this.__data__._current.x1, |
|
'y0': this.__data__._current.y0, |
|
'y1': this.__data__._current.y1} |
|
|
|
var new_vals = {'x0': d.x0, |
|
'x1': d.x1, |
|
'y0': d.y0, |
|
'y1': d.y1} |
|
|
|
var i = d3.interpolate(old_vals, new_vals); |
|
this.__data__._current = i(0); |
|
return function(t) { |
|
return arc(i(t)); |
|
}; |
|
} |
|
|
|
// Fade all but the current sequence, and show it in the breadcrumb trail. |
|
function mouseover(d) { |
|
if (updating == 0) { |
|
var percentage = (100 * d.value / totalSize).toPrecision(3); |
|
var percentageString = d.value + " (" + percentage + "%)"; |
|
if (percentage < 0.1) { |
|
percentageString = d.value + " (< 0.1%)"; |
|
} |
|
|
|
d3.select("#percentage") |
|
.text(percentageString); |
|
|
|
d3.select("#explanation") |
|
.style("visibility", ""); |
|
|
|
var sequenceArray = d.ancestors().sort(function (a, b) { return a.depth - b.depth; }); |
|
sequenceArray.shift(); |
|
updateBreadcrumbs(sequenceArray, percentageString); |
|
|
|
// Fade all the segments. |
|
d3.selectAll("path") |
|
.style("opacity", 0.10); |
|
|
|
// Then highlight only those that are an ancestor of the current segment. |
|
vis.selectAll("path") |
|
.filter(function (node) { |
|
return (sequenceArray.indexOf(node) >= 0); |
|
}) |
|
.style("opacity", 1); |
|
} |
|
} |
|
|
|
// Restore everything to full opacity when moving off the visualization. |
|
function mouseleave(d) { |
|
if (updating == 0) { |
|
// Hide the breadcrumb trail |
|
d3.select("#trail") |
|
.style("visibility", "hidden"); |
|
|
|
var path = d3.selectAll("path"); |
|
|
|
// Deactivate all segments during transition. |
|
path.on("mouseover", null); |
|
|
|
// Transition each segment to full opacity and then reactivate it. |
|
path |
|
.transition("brighten") |
|
.duration(250) |
|
.style("opacity", 1) |
|
.on("end", function () { |
|
d3.select(this).on("mouseover", mouseover); |
|
}); |
|
|
|
d3.select("#explanation") |
|
.style("visibility", "hidden"); |
|
} |
|
} |
|
|
|
// Given a node in a partition layout, return an array of all of its ancestor |
|
// 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; |
|
} |
|
|
|
function initializeBreadcrumbTrail() { |
|
// Add the svg area. |
|
var trail = d3.select("#sequence").append("svg:svg") |
|
.attr("width", width) |
|
.attr("height", height * 2) |
|
.attr("id", "trail"); |
|
} |
|
|
|
// Generate a string that describes the points of a breadcrumb polygon. |
|
function breadcrumbPoints(d, i) { |
|
var addonThickness = 0; |
|
if (!("children" in d)) { // Very last breadcrumb, show breadcrumb shape as flat at the bottom |
|
addonThickness = b.t; |
|
} |
|
|
|
var points = []; |
|
points.push(b.w + ",0"); |
|
points.push(b.w + "," + (b.h + addonThickness)); |
|
points.push((b.w / 2) + "," + (b.h + b.t)); |
|
points.push("0," + (b.h + addonThickness)); |
|
points.push("0,0"); |
|
if (i > 0) { // Topmost breadcrumb; don't include 6th vertex. |
|
points.push((b.w / 2) + "," + b.t); |
|
} |
|
return points.join(" "); |
|
} |
|
|
|
// Update the breadcrumb trail to show the current sequence and percentage. |
|
function updateBreadcrumbs(nodeArray, percentageString) { |
|
|
|
// Data join; key function combines name and depth (= position in sequence). |
|
var g = d3.select("#trail") |
|
.selectAll("g") |
|
.data(nodeArray, function(d) { return d.data.name + d.depth; }); |
|
|
|
// Add breadcrumb and label for entering nodes. |
|
var entering = g.enter().append("svg:g"); |
|
|
|
entering.append("svg:polygon") |
|
.attr("points", breadcrumbPoints) |
|
.style("fill", function(d) { return colors[d.data.name].fill; }); |
|
|
|
entering.append("svg:text") |
|
.attr("x", (b.w / 2)) |
|
.attr("y", ((b.h + b.s) / 2)) |
|
.attr("dy", "0.35em") |
|
.attr("text-anchor", "middle") |
|
.text(function(d) { return d.data.name; }); |
|
|
|
// Remove exiting nodes. |
|
g.exit().remove(); |
|
|
|
// Set position for entering and updating nodes. |
|
entering.attr("transform", function(d, i) { |
|
return "translate(0," + i * (b.h + b.s) + ")"; |
|
}); |
|
|
|
// Make the breadcrumb trail visible, if it's hidden. |
|
d3.select("#trail") |
|
.style("visibility", ""); |
|
} |
|
|
|
// Required during initial set up of legend |
|
function drawLegend() { |
|
var legend = d3.select("#legend").append("svg:svg") |
|
.attr("width", li.w) |
|
.attr("height", d3.keys(colors).length * (li.h + li.s)); |
|
|
|
updateLegend(); |
|
} |
|
|
|
function updateLegend() { |
|
var legend = d3.select("#legend") |
|
|
|
var svg = legend.select("svg") |
|
.attr("height", d3.keys(colors).length * (li.h + li.s)); // Resize legend svg box to new size of colours array |
|
|
|
svg.selectAll("g").remove(); |
|
|
|
// Order the colours so the legend appears in alphabetical order |
|
var orderedColors = {}; |
|
Object.keys(colors).sort().forEach(function (key) { |
|
orderedColors[key] = colors[key]; |
|
}); |
|
|
|
var g = svg.selectAll("g").data(d3.entries(orderedColors), function (d) { return d.key; }) |
|
|
|
// Append new legend elements |
|
var entering = g.enter().append("svg:g") |
|
.filter(function(d) { return d.value.active == 1; }) |
|
.attr("transform", function(d, i) { |
|
return "translate(0," + i * (li.h + li.s) + ")"; |
|
}); |
|
|
|
// Add new legend boxes |
|
entering.append("svg:rect") |
|
.attr("rx", li.r) |
|
.attr("ry", li.r) |
|
.attr("width", li.w) |
|
.attr("height", li.h) |
|
.style("fill", function(d) { return d.value.fill; }); |
|
|
|
// Add new legend text |
|
entering.append("svg:text") |
|
.attr("x", li.w / 2) |
|
.attr("y", li.h / 2) |
|
.attr("dy", "0.35em") |
|
.attr("text-anchor", "middle") |
|
.text(function (d) { return d.key; }); |
|
|
|
//g.exit().remove(); |
|
} |
|
|
|
// Display or hide legend |
|
function toggleLegend() { |
|
var legend = d3.select("#legend"); |
|
if (legend.style("visibility") == "hidden") { |
|
legend.style("visibility", ""); |
|
} else { |
|
legend.style("visibility", "hidden"); |
|
} |
|
} |
|
|
|
// Disable on mouseover and mouseleave during click process |
|
function click() { |
|
if (updating == 0) { |
|
var cont = d3.select("#container") |
|
var path = d3.selectAll("path").on("mouseover", null); |
|
|
|
if (lockStatus == 0) { // Lock current view |
|
cont.on("mouseleave", null); |
|
path.on("mouseover", null); |
|
lockStatus = 1; |
|
} else { // Unlock current view |
|
cont.on("mouseleave", mouseleave); |
|
path.on("mouseover", mouseover); |
|
lockStatus = 0; |
|
} |
|
} |
|
} |
|
|
|
// Zoom in on specific area of chart |
|
function dblclick(d) { |
|
if (updating == 0) { |
|
// Disable on click and double click events while we complete transition |
|
updating = 1; |
|
|
|
if (d.depth == 0) { |
|
tweenActive = 0; |
|
} else { |
|
tweenActive = 1; |
|
} |
|
|
|
vis.transition("zoom") |
|
.duration(dblclickDuration) |
|
.tween("scale", function () { |
|
var xd = d3.interpolate(x.domain(), [d.x0, d.x1]), |
|
yd = d3.interpolate(y.domain(), [d.y0, 1]), |
|
yr = d3.interpolate(y.range(), [d.y0 ? 20 : 0, radius]); |
|
return function (t) { |
|
x.domain(xd(t)); |
|
y.domain(yd(t)).range(yr(t)); |
|
}; |
|
}) |
|
.selectAll("path") |
|
.attrTween("d", function (d) { return function () { return arc(d); }; }); |
|
|
|
setTimeout(function () { |
|
updating = 0; |
|
}, dblclickDuration + 10); |
|
} |
|
} |
|
|
|
// Take a 2-column CSV and transform it into a hierarchical structure suitable |
|
// for a partition layout. The first column is a sequence of step names, from |
|
// root to leaf, separated by hyphens. The second column is a count of how |
|
// often that sequence occurred. |
|
function buildHierarchy(csv) { |
|
var root = {"name": "root", "children": []}; |
|
for (var i = 0; i < csv.length; i++) { |
|
var sequence = csv[i][0]; |
|
var size = +csv[i][1]; |
|
if (isNaN(size)) { // e.g. if this is a header row |
|
continue; |
|
} |
|
var parts = sequence.split("-"); |
|
var currentNode = root; |
|
for (var j = 0; j < parts.length; j++) { |
|
var children = currentNode["children"]; |
|
var nodeName = parts[j]; |
|
var childNode; |
|
if (j + 1 < parts.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 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; |
|
}; |