viz-lib/src/visualizations/sunburst/initSunburst.js (213 lines of code) (raw):
/**
* The following is based on @chrisrzhou's example from: http://bl.ocks.org/chrisrzhou/d5bdd8546f64ca0e4366.
*/
import * as d3 from "d3";
import { has, map, keys, groupBy, sortBy, filter, find, compact, first, every, identity } from "lodash";
const exitNode = "<<<Exit>>>";
const colors = d3.scale.category10();
// helper function colorMap - color gray if "end" is detected
function colorMap(d) {
return colors(d.name);
}
// Return array of ancestors of nodes, highest first, but excluding the root.
function getAncestors(node) {
const path = [];
let current = node;
while (current.parent) {
path.unshift(current);
current = current.parent;
}
return path;
}
function buildNodesFromHierarchyData(data) {
const grouped = groupBy(data, "sequence");
return map(grouped, value => {
const sorted = sortBy(value, "stage");
return {
size: value[0].value || 0,
sequence: value[0].sequence,
nodes: map(sorted, i => i.node),
};
});
}
function buildNodesFromTableData(data) {
const validKey = key => key !== "value";
const dataKeys = sortBy(filter(keys(data[0]), validKey), identity);
return map(data, (row, sequence) => ({
size: row.value || 0,
sequence,
nodes: compact(map(dataKeys, key => row[key])),
}));
}
function isDataInHierarchyFormat(data) {
const firstRow = first(data);
return every(["sequence", "stage", "node", "value"], field => has(firstRow, field));
}
function buildHierarchy(data) {
data = isDataInHierarchyFormat(data) ? buildNodesFromHierarchyData(data) : buildNodesFromTableData(data);
// build tree
const root = {
name: "root",
children: [],
};
data.forEach(d => {
const nodes = d.nodes;
const size = parseInt(d.size, 10);
// build graph, nodes, and child nodes
let currentNode = root;
for (let j = 0; j < nodes.length; j += 1) {
let children = currentNode.children;
const nodeName = nodes[j];
const isLeaf = j + 1 === nodes.length;
if (!children) {
currentNode.children = children = [];
children.push({
name: exitNode,
size: currentNode.size,
});
}
let childNode = find(children, child => child.name === nodeName);
if (isLeaf && childNode) {
childNode.children = childNode.children || [];
childNode.children.push({
name: exitNode,
size,
});
} else if (isLeaf) {
children.push({
name: nodeName,
size,
});
} else {
if (!childNode) {
childNode = {
name: nodeName,
children: [],
};
children.push(childNode);
}
currentNode = childNode;
}
}
});
return root;
}
function isDataValid(data) {
return data && data.rows.length > 0;
}
export default function initSunburst(data) {
if (!isDataValid(data)) {
return element => {
d3.select(element)
.selectAll("*")
.remove();
};
}
data = buildHierarchy(data.rows);
return element => {
d3.select(element)
.selectAll("*")
.remove();
// svg dimensions
const width = element.clientWidth;
const height = element.offsetHeight;
// Breadcrumb dimensions: width, height, spacing, width of tip/tail.
const b = {
w: width / 6,
h: 30,
s: 3,
t: 10,
};
const radius = Math.min(width - b.h, height - b.h) / 2 - 5;
if (radius <= 0) {
return;
}
// margins
const margin = {
top: radius,
bottom: 50,
left: radius,
right: 0,
};
// Drawing variables: e.g. colors, totalSize, partitions, arcs
// Total size of all nodes, to be used later when data is loaded
let totalSize = 0;
// create d3.layout.partition
const partition = d3.layout
.partition()
.size([2 * Math.PI, radius * radius])
.value(d => d.size);
// create arcs for drawing D3 paths
const arc = d3.svg
.arc()
.startAngle(d => d.x)
.endAngle(d => d.x + d.dx)
.innerRadius(d => Math.sqrt(d.y))
.outerRadius(d => Math.sqrt(d.y + d.dy));
/**
* Define and initialize D3 select references and div-containers
*
* e.g. vis, breadcrumbs, lastCrumb, summary, sunburst, legend
*/
const vis = d3.select(element);
// create and position breadcrumbs container and svg
const breadcrumbs = vis
.append("div")
.classed("breadcrumbs-container", true)
.append("svg")
.attr("width", width)
.attr("height", b.h)
.attr("fill", "white")
.attr("font-weight", 600);
// create and position SVG
const container = vis.append("div");
// create and position summary container
const summary = container.append("div").classed("summary-container", true);
const sunburst = container
.append("div")
.classed("sunburst-container", true)
.append("svg")
.attr("width", radius * 2)
.attr("height", radius * 2)
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
// create last breadcrumb element
const lastCrumb = breadcrumbs.append("text").classed("lastCrumb", true);
// Generate a string representation for drawing a breadcrumb polygon.
function breadcrumbPoints(d, i) {
const 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.
const g = breadcrumbs.selectAll("g").data(ancestors, d => d.name + d.depth);
// Add breadcrumb and label for entering nodes.
const 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(d => d.name);
// Set position for entering and updating nodes.
g.attr("transform", (d, i) => `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);
}
// helper function mouseover to handle mouseover events/animations and calculation
// of ancestor nodes etc
function mouseover(d) {
// build percentage string
const percentage = ((100 * d.value) / totalSize).toPrecision(3);
let percentageString = `${percentage}%`;
if (percentage < 1) {
percentageString = "< 1.0%";
}
// update breadcrumbs (get all ancestors)
const 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(node => ancestors.indexOf(node) >= 0)
.attr("opacity", 1);
// update summary
summary.html(`
<span>Stage: ${d.depth}</span>
<span class='percentage' style='font-size: 2em;'>${percentageString}</span>
<span>${d.value} of ${totalSize}</span>
`);
// display summary and breadcrumbs if hidden
summary.style("visibility", "");
breadcrumbs.style("visibility", "");
}
// helper function click to handle mouseleave events/animations
function click() {
// 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 endClick() {
d3.select(this).on("mouseover", mouseover);
});
// hide summary and breadcrumbs if visible
breadcrumbs.style("visibility", "hidden");
summary.style("visibility", "hidden");
}
// Build only nodes of a threshold "visible" sizes to improve efficiency
// 0.005 radians = 0.29 degrees
const nodes = partition.nodes(data).filter(d => d.dx > 0.005 && d.name !== exitNode);
// this section is required to update the colors.domain() every time the data updates
const uniqueNames = (function uniqueNames(a) {
const output = [];
a.forEach(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
const path = sunburst
.data([data])
.selectAll("path")
.data(nodes)
.enter()
.append("path")
.classed("nodePath", true)
.attr("display", d => (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;
};
}