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; }; }