diff --git a/panoramix/assets/javascripts/modules/utils.js b/panoramix/assets/javascripts/modules/utils.js new file mode 100644 index 000000000000..e582ab65b05f --- /dev/null +++ b/panoramix/assets/javascripts/modules/utils.js @@ -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 +}; diff --git a/panoramix/assets/visualizations/sunburst.css b/panoramix/assets/visualizations/sunburst.css index 246e582de96c..d2636faaa396 100644 --- a/panoramix/assets/visualizations/sunburst.css +++ b/panoramix/assets/visualizations/sunburst.css @@ -1,24 +1,39 @@ -.sunburst text.middle{ - text-anchor: middle; +.sunburst text { + shape-rendering: crispEdges; } - -.sunburst #sequence { +.sunburst path { + stroke: #333; + stroke-width: 0.5px; } - -.sunburst #legend { - padding: 10px 0 0 3px; +.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 #sequence text, #legend text { +.sunburst .breadcrumbs text { font-weight: 600; - fill: #fff; + font-size: 1.2em; + text-anchor: middle; + fill: #000; } -.sunburst path { - stroke: #fff; +/* dashboard specific */ +.dashboard .sunburst text { + font-size: 1em; } - -.sunburst #percentage { +.dashboard .sunburst .path-percent { font-size: 2.5em; } - +.dashboard .sunburst .path-metrics { + font-size: 1em; +} diff --git a/panoramix/assets/visualizations/sunburst.js b/panoramix/assets/visualizations/sunburst.js index 9b6157ce2f3f..1e614ce946a1 100644 --- a/panoramix/assets/visualizations/sunburst.js +++ b/panoramix/assets/visualizations/sunburst.js @@ -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; - - // 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 render = function () { + // 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 + + 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 diff --git a/panoramix/viz.py b/panoramix/viz.py index 707abd18e2c4..83cd0c8a593b 100644 --- a/panoramix/viz.py +++ b/panoramix/viz.py @@ -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',