Merge pull request #184 from mistercrunch/chris/sunburst-fixes

sunburst improvements
This commit is contained in:
Chris Williams 2016-03-17 17:54:10 -07:00
commit 8f4f5b126a
4 changed files with 253 additions and 97 deletions

View File

@ -0,0 +1,55 @@
var d3 = require('d3');
/*
Utility function that takes a d3 svg:text selection and a max width, and splits the
text's text across multiple tspan lines such that any given line does not exceed max width
If text does not span multiple lines AND adjustedY is passed, will set the text to the passed val
*/
function wrapSvgText(text, width, adjustedY) {
var lineHeight = 1; // ems
text.each(function () {
var text = d3.select(this),
words = text.text().split(/\s+/),
word,
line = [],
lineNumber = 0,
x = text.attr("x"),
y = text.attr("y"),
dy = parseFloat(text.attr("dy")),
tspan = text.text(null)
.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", dy + "em");
var didWrap = false;
for (var i = 0; i < words.length; i++) {
word = words[i];
line.push(word);
tspan.text(line.join(" "));
if (tspan.node().getComputedTextLength() > width) {
line.pop(); // remove word that pushes over the limit
tspan.text(line.join(" "));
line = [word];
tspan = text.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", ++lineNumber * lineHeight + dy + "em")
.text(word);
didWrap = true;
}
}
if (!didWrap && typeof adjustedY !== "undefined") {
tspan.attr("y", adjustedY);
}
});
}
module.exports = {
wrapSvgText: wrapSvgText
};

View File

@ -1,24 +1,39 @@
.sunburst text.middle{
text-anchor: middle;
.sunburst text {
shape-rendering: crispEdges;
}
.sunburst #sequence {
}
.sunburst #legend {
padding: 10px 0 0 3px;
}
.sunburst #sequence text, #legend text {
font-weight: 600;
fill: #fff;
}
.sunburst path {
stroke: #fff;
stroke: #333;
stroke-width: 0.5px;
}
.sunburst .center-label {
text-anchor: middle;
fill: #000;
pointer-events: none;
}
.sunburst .path-percent {
font-size: 4em;
}
.sunburst .path-metrics {
font-size: 1.75em;
}
.sunburst .path-ratio {
font-size: 1.2em;
}
.sunburst #percentage {
.sunburst .breadcrumbs text {
font-weight: 600;
font-size: 1.2em;
text-anchor: middle;
fill: #000;
}
/* dashboard specific */
.dashboard .sunburst text {
font-size: 1em;
}
.dashboard .sunburst .path-percent {
font-size: 2.5em;
}
.dashboard .sunburst .path-metrics {
font-size: 1em;
}

View File

@ -1,35 +1,33 @@
var d3 = window.d3 || require('d3');
var px = require('../javascripts/modules/panoramix.js');
var wrapSvgText = require('../javascripts/modules/utils.js').wrapSvgText;
require('./sunburst.css');
// Modified from http://bl.ocks.org/kerryrodden/7090426
function sunburstVis(slice) {
var container = d3.select(slice.selector);
var render = function () {
var width = slice.width();
var height = slice.height() - 5;
// vars with shared scope within this function
var margin = { top: 10, right: 5, bottom: 10, left: 5 };
var containerWidth = slice.width();
var containerHeight = slice.height();
var breadcrumbHeight = containerHeight * 0.085;
var visWidth = containerWidth - margin.left - margin.right;
var visHeight = containerHeight - margin.top - margin.bottom - breadcrumbHeight;
var radius = Math.min(visWidth, visHeight) / 2;
var colorByCategory = true; // color by category if primary/secondary metrics match
// Total size of all segments; we set this later, after loading the data.
var totalSize = 0;
var radius = Math.min(width, height) / 2;
container.select("svg").remove();
var vis = container.append("svg:svg")
.attr("width", width)
.attr("height", height)
.append("svg:g")
.attr("id", "container")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
var arcs = vis.append("svg:g").attr("id", "arcs");
var gMiddleText = vis.append("svg:g").attr("id", "gMiddleText");
var maxBreadcrumbs, breadcrumbDims, // set based on data
totalSize, // total size of all segments; set after loading the data.
colorScale,
breadcrumbs, vis, arcs, gMiddleText; // dom handles
// Helper + path gen functions
var partition = d3.layout.partition()
.size([2 * Math.PI, radius * radius])
.value(function (d) {
return d.m1;
});
.value(function (d) { return d.m1; });
var arc = d3.svg.arc()
.startAngle(function (d) {
@ -45,21 +43,60 @@ function sunburstVis(slice) {
return Math.sqrt(d.y + d.dy);
});
var ext;
d3.json(slice.jsonEndpoint(), function (error, json) {
var f = d3.format(".3s");
var fp = d3.format(".3p");
container.select("svg").remove();
var svg = container.append("svg:svg")
.attr("width", containerWidth)
.attr("height", containerHeight);
d3.json(slice.jsonEndpoint(), function (error, rawData) {
if (error !== null) {
slice.error(error.responseText);
return '';
}
var tree = buildHierarchy(json.data);
createVisualization(tree);
slice.done(json);
createBreadcrumbs(rawData);
createVisualization(rawData);
slice.done(rawData);
});
function createBreadcrumbs(rawData) {
var firstRowData = rawData.data[0];
maxBreadcrumbs = (firstRowData.length - 2) + 1; // -2 bc row contains 2x metrics, +extra for %label and buffer
breadcrumbDims = {
width: visWidth / maxBreadcrumbs,
height: breadcrumbHeight *0.8, // more margin
spacing: 3,
tipTailWidth: 10
};
breadcrumbs = svg.append("svg:g")
.attr("class", "breadcrumbs")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
breadcrumbs.append("svg:text")
.attr("class", "end-label");
}
// Main function to draw and set up the visualization, once we have the data.
function createVisualization(json) {
function createVisualization(rawData) {
var tree = buildHierarchy(rawData.data);
vis = svg.append("svg:g")
.attr("class", "sunburst-vis")
.attr("transform", "translate(" + (margin.left + (visWidth / 2)) + "," + (margin.top + breadcrumbHeight + (visHeight / 2)) + ")")
.on("mouseleave", mouseleave);
arcs = vis.append("svg:g")
.attr("id", "arcs");
gMiddleText = vis.append("svg:g")
.attr("class", "center-label");
// Bounding circle underneath the sunburst, to make it easier to detect
// when the mouse leaves the parent g.
@ -68,86 +105,73 @@ function sunburstVis(slice) {
.style("opacity", 0);
// For efficiency, filter nodes to keep only those large enough to see.
var nodes = partition.nodes(json)
var nodes = partition.nodes(tree)
.filter(function (d) {
return (d.dx > 0.005); // 0.005 radians = 0.29 degrees
});
ext = d3.extent(nodes, function (d) {
return d.m2 / d.m1;
});
var colorScale = d3.scale.linear()
.domain([ext[0], ext[0] + ((ext[1] - ext[0]) / 2), ext[1]])
.range(["#00D1C1", "white", "#FFB400"]);
var ext;
var path = arcs.data([json]).selectAll("path")
if (rawData.form_data.metric !== rawData.form_data.secondary_metric) {
colorByCategory = false;
ext = d3.extent(nodes, function (d) {
return d.m2 / d.m1;
});
colorScale = d3.scale.linear()
.domain([ext[0], ext[0] + ((ext[1] - ext[0]) / 2), ext[1]])
.range(["#00D1C1", "white", "#FFB400"]);
}
var path = arcs.data([tree]).selectAll("path")
.data(nodes)
.enter().append("svg:path")
.enter().append("svg:path")
.attr("display", function (d) {
return d.depth ? null : "none";
})
.attr("d", arc)
.attr("fill-rule", "evenodd")
.style("stroke", "grey")
.style("stroke-width", "1px")
.style("fill", function (d) {
return colorScale(d.m2 / d.m1);
return colorByCategory ? px.color.category21(d.name) : colorScale(d.m2 / d.m1);
})
.style("opacity", 1)
.on("mouseenter", mouseenter);
// Add the mouseleave handler to the bounding circle.
container.select("#container").on("mouseleave", mouseleave);
// Get total size of the tree = value of root node from partition.
totalSize = path.node().__data__.value;
}
var f = d3.format(".3s");
var fp = d3.format(".3p");
// Fade all but the current sequence, and show it in the breadcrumb trail.
function mouseenter(d) {
var percentage = (d.m1 / totalSize).toPrecision(3);
var percentageString = fp(percentage);
var metricsMatch = Math.abs(d.m1 - d.m2) < 0.000001;
gMiddleText.selectAll("*").remove();
gMiddleText.append("text")
.classed("middle", true)
.style("font-size", "50px")
.attr("class", "path-percent")
.attr("y", "-10")
.text(percentageString);
gMiddleText.append("text")
.classed("middle", true)
.style("font-size", "20px")
.attr("class", "path-metrics")
.attr("y", "25")
.text("m1: " + f(d.m1) + " | m2: " + f(d.m2));
.text("m1: " + f(d.m1) + (metricsMatch ? "" : ", m2: " + f(d.m2)));
gMiddleText.append("text")
.classed("middle", true)
.style("font-size", "15px")
.attr("class", "path-ratio")
.attr("y", "50")
.text("m2/m1: " + fp(d.m2 / d.m1));
var sequenceArray = getAncestors(d);
// Update the breadcrumb trail to show the current sequence and percentage.
function updateBreadcrumbs(nodeArray) {
var l = [];
for (var i = 0; i < nodeArray.length; i++) {
l.push(nodeArray[i].name);
}
var s = l.join(' > ');
gMiddleText.append("text")
.text(s)
.classed("middle", true)
.attr("y", -75);
}
updateBreadcrumbs(sequenceArray);
// Fade all the segments.
// Reset and fade all the segments.
arcs.selectAll("path")
.style("stroke-width", "1px")
.style("stroke-width", null)
.style("stroke", null)
.style("opacity", 0.3);
// Then highlight only those that are an ancestor of the current segment.
@ -156,16 +180,17 @@ function sunburstVis(slice) {
return (sequenceArray.indexOf(node) >= 0);
})
.style("opacity", 1)
.style("stroke", "#888")
.style("stroke-width", "2px");
.style("stroke-width", "2px")
.style("stroke", "#000");
updateBreadcrumbs(sequenceArray, percentageString);
}
// Restore everything to full opacity when moving off the visualization.
function mouseleave(d) {
// Hide the breadcrumb trail
arcs.select("#trail")
.style("visibility", "hidden");
breadcrumbs.style("visibility", "hidden");
gMiddleText.selectAll("*").remove();
@ -178,8 +203,8 @@ function sunburstVis(slice) {
.transition()
.duration(200)
.style("opacity", 1)
.style("stroke", "grey")
.style("stroke-width", "1px")
.style("stroke", null)
.style("stroke-width", null)
.each("end", function () {
d3.select(this).on("mouseenter", mouseenter);
});
@ -197,6 +222,62 @@ function sunburstVis(slice) {
return path;
}
// Generate a string that describes the points of a breadcrumb polygon.
function breadcrumbPoints(d, i) {
var points = [];
points.push("0,0");
points.push(breadcrumbDims.width + ",0");
points.push(breadcrumbDims.width + breadcrumbDims.tipTailWidth + "," + (breadcrumbDims.height / 2));
points.push(breadcrumbDims.width+ "," + breadcrumbDims.height);
points.push("0," + breadcrumbDims.height);
if (i > 0) { // Leftmost breadcrumb; don't include 6th vertex.
points.push(breadcrumbDims.tipTailWidth + "," + (breadcrumbDims.height / 2));
}
return points.join(" ");
}
function updateBreadcrumbs(sequenceArray, percentageString) {
var g = breadcrumbs.selectAll("g")
.data(sequenceArray, function (d) {
return d.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 colorByCategory ? px.color.category21(d.name) : colorScale(d.m2 / d.m1);
});
entering.append("svg:text")
.attr("x", (breadcrumbDims.width + breadcrumbDims.tipTailWidth) / 2)
.attr("y", breadcrumbDims.height / 4)
.attr("dy", "0.35em")
.attr("class", "step-label")
.text(function (d) { return d.name; })
.call(wrapSvgText, breadcrumbDims.width, breadcrumbDims.height / 2);
// Set position for entering and updating nodes.
g.attr("transform", function (d, i) {
return "translate(" + i * (breadcrumbDims.width + breadcrumbDims.spacing) + ", 0)";
});
// Remove exiting nodes.
g.exit().remove();
// Now move and update the percentage at the end.
breadcrumbs.select(".end-label")
.attr("x", (sequenceArray.length + 0.5) * (breadcrumbDims.width + breadcrumbDims.spacing))
.attr("y", breadcrumbDims.height / 2)
.attr("dy", "0.35em")
.text(percentageString);
// Make the breadcrumb trail visible, if it's hidden.
breadcrumbs.style("visibility", null);
}
function buildHierarchy(rows) {
var root = {
name: "root",
@ -206,16 +287,19 @@ function sunburstVis(slice) {
var row = rows[i];
var m1 = Number(row[row.length - 2]);
var m2 = Number(row[row.length - 1]);
var parts = row.slice(0, row.length - 2);
var levels = row.slice(0, row.length - 2);
if (isNaN(m1)) { // e.g. if this is a header row
continue;
}
var currentNode = root;
for (var j = 0; j < parts.length; j++) {
for (var j = 0; j < levels.length; j++) {
var children = currentNode.children;
var nodeName = parts[j];
var nodeName = levels[j];
// If the next node has the name "0", it will
var isLeafNode = (j >= levels.length - 1) || levels[j+1] === 0;
var childNode;
if (j + 1 < parts.length) {
if (!isLeafNode) {
// Not yet at the end of the sequence; move down the tree.
var foundChild = false;
for (var k = 0; k < children.length; k++) {
@ -234,7 +318,7 @@ function sunburstVis(slice) {
children.push(childNode);
}
currentNode = childNode;
} else {
} else if (nodeName !== 0) {
// Reached the end of the sequence; create a leaf node.
childNode = {
name: nodeName,
@ -265,6 +349,7 @@ function sunburstVis(slice) {
return root;
}
};
return {
render: render,
resize: render

View File

@ -870,7 +870,8 @@ class SunburstViz(BaseViz):
'label': 'Secondary Metric',
'description': (
"This secondary metric is used to "
"define the color as a ratio against the primary metric"),
"define the color as a ratio against the primary metric. "
"If the two metrics match, color is mapped level groups"),
},
'groupby': {
'label': 'Hierarchy',