From f5b4a0290e5aaa6ae35527ff66a2f96769903767 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 9 Dec 2015 13:57:14 -0800 Subject: [PATCH] New viz: sunbursts --- panoramix/forms.py | 6 +- panoramix/static/widgets/viz_nvd3.js | 2 +- panoramix/static/widgets/viz_sunburst.css | 37 +++ panoramix/static/widgets/viz_sunburst.js | 251 ++++++++++++++++++ .../templates/panoramix/viz_sunburst.html | 14 + panoramix/viz.py | 39 +++ 6 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 panoramix/static/widgets/viz_sunburst.css create mode 100644 panoramix/static/widgets/viz_sunburst.js create mode 100644 panoramix/templates/panoramix/viz_sunburst.html diff --git a/panoramix/forms.py b/panoramix/forms.py index b37ba09eba445..b0181ee3a3c35 100644 --- a/panoramix/forms.py +++ b/panoramix/forms.py @@ -76,7 +76,11 @@ def __init__(self, viz): 'metric': SelectField( 'Metric', choices=datasource.metrics_combo, default=default_metric, - description="One or many metrics to display"), + description="Chose the metric"), + 'secondary_metric': SelectField( + 'Color Metric', choices=datasource.metrics_combo, + default=default_metric, + description="A metric to use for color"), 'groupby': BetterSelectMultipleField( 'Group by', choices=self.choicify(datasource.groupby_column_names), diff --git a/panoramix/static/widgets/viz_nvd3.js b/panoramix/static/widgets/viz_nvd3.js index 86d1408dc2320..156f080fe5487 100644 --- a/panoramix/static/widgets/viz_nvd3.js +++ b/panoramix/static/widgets/viz_nvd3.js @@ -1,5 +1,6 @@ function viz_nvd3(data_attribute) { var token_name = data_attribute['token']; + var token = d3.select('#' + token_name); var json_callback = data_attribute['json_endpoint']; var chart = undefined; @@ -25,7 +26,6 @@ function viz_nvd3(data_attribute) { "#FF5A5F", "#007A87", "#7B0051", "#00D1C1", "#8CE071", "#FFB400", "#FFAA91", "#B4A76C", "#9CA299", "#565A5C" ]; - var token = d3.select('#' + token_name); var jtoken = $('#' + token_name); var loading = $('#' + token_name).find("img.loading"); var chart_div = $('#' + token_name).find("div.chart"); diff --git a/panoramix/static/widgets/viz_sunburst.css b/panoramix/static/widgets/viz_sunburst.css new file mode 100644 index 0000000000000..45681d372fbbb --- /dev/null +++ b/panoramix/static/widgets/viz_sunburst.css @@ -0,0 +1,37 @@ +#sidebar { + float: right; + width: 100px; +} + +text.middle{ + text-anchor: middle; +} + +#sequence { +} + +#legend { + padding: 10px 0 0 3px; +} + +#sequence text, #legend text { + font-weight: 600; + fill: #fff; +} + +#chart { + height: 100%; +} + +div.token { + height: 100%; +} + +#chart path { + stroke: #fff; +} + +#percentage { + font-size: 2.5em; +} + diff --git a/panoramix/static/widgets/viz_sunburst.js b/panoramix/static/widgets/viz_sunburst.js new file mode 100644 index 0000000000000..52d93fdf79caa --- /dev/null +++ b/panoramix/static/widgets/viz_sunburst.js @@ -0,0 +1,251 @@ +/* +Modified from http://bl.ocks.org/kerryrodden/7090426 +*/ + +function viz_sunburst(data_attribute) { + var token = d3.select('#' + data_attribute.token); + var render = function() { + // Breadcrumb dimensions: width, height, spacing, width of tip/tail. + var b = { + w: 100, h: 30, s: 3, t: 10 + }; + var colorScale; + + // Total size of all segments; we set this later, after loading the data. + var totalSize = 0; + var div = token.select("#chart"); + var xy = div.node().getBoundingClientRect(); + var width = xy.width; + var height = xy.height - 25; + var radius = Math.min(width, height) / 2; + + var vis = div.append("svg:svg") + .attr("width", width) + .attr("height", height) + .append("svg:g") + .attr("id", "container") + .attr("transform", "translate(" + width / 2 + "," + height / 2 + ")"); + + var gMiddleText = vis.append("svg:g").attr("id", "gMiddleText"); + + var partition = d3.layout.partition() + .size([2 * Math.PI, radius * radius]) + .value(function(d) { return d.m1; }); + + var arc = d3.svg.arc() + .startAngle(function(d) { return d.x; }) + .endAngle(function(d) { return d.x + d.dx; }) + .innerRadius(function(d) { return Math.sqrt(d.y); }) + .outerRadius(function(d) { return Math.sqrt(d.y + d.dy); }); + + var ext; + d3.json(data_attribute.json_endpoint, function(json){ + var tree = buildHierarchy(json.data); + createVisualization(tree); + token.select("img.loading").remove(); + }); + + // Main function to draw and set up the visualization, once we have the data. + function createVisualization(json) { + + // 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) + .style("opacity", 0); + + // For efficiency, filter nodes to keep only those large enough to see. + var nodes = partition.nodes(json) + .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;}); + + colorScale = d3.scale.linear() + .domain([ext[0], ext[0] + ((ext[1] - ext[0]) / 2), ext[1]]) + .range(["#00D1C1", "white","#FFB400"]); + + var path = vis.data([json]).selectAll("path") + .data(nodes) + .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); }) + .style("opacity", 1) + .on("mouseenter", mouseenter); + + + // Add the mouseleave handler to the bounding circle. + token.select("#container").on("mouseleave", mouseleave); + + // Get total size of the tree = value of root node from partition. + totalSize = path.node().__data__.value; + }; + f = d3.format(".3s"); + 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); + + gMiddleText.selectAll("*").remove(); + gMiddleText.append("text") + .classed("middle", true) + .style("font-size", "50px") + .text(percentageString); + gMiddleText.append("text") + .classed("middle", true) + .style("font-size", "20px") + .attr("y", "25") + .text("m1: " + f(d.m1) + " | m2: " + f(d.m2)); + gMiddleText.append("text") + .classed("middle", true) + .style("font-size", "15px") + .attr("y", "50") + .text("m2/m1: " + fp(d.m2/d.m1)); + + var sequenceArray = getAncestors(d); + updateBreadcrumbs(sequenceArray, percentageString); + + // Fade all the segments. + token.selectAll("path") + .style("stroke-width", "1px") + .style("opacity", 0.3); + + // Then highlight only those that are an ancestor of the current segment. + token.selectAll("path") + .filter(function(node) { + return (sequenceArray.indexOf(node) >= 0); + }) + .style("opacity", 1) + .style("stroke", "black") + .style("stroke-width", "2px"); + } + + // Restore everything to full opacity when moving off the visualization. + function mouseleave(d) { + + // Hide the breadcrumb trail + token.select("#trail") + .style("visibility", "hidden"); + gMiddleText.selectAll("*").remove(); + + // Deactivate all segments during transition. + token.selectAll("path").on("mouseenter", null); + //gMiddleText.selectAll("*").remove(); + + // Transition each segment to full opacity and then reactivate it. + token.selectAll("path") + .transition() + .duration(200) + .style("opacity", 1) + .style("stroke", "grey") + .style("stroke-width", "1px") + .each("end", function() { + d3.select(this).on("mouseenter", mouseenter); + }); + } + + // 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; + } + + // Generate a string that describes the points of a breadcrumb polygon. + function breadcrumbPoints(d, i) { + var 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 trail to show the current sequence and percentage. + function updateBreadcrumbs(nodeArray, percentageString) { + l = []; + for(var i=0; i ') + gMiddleText.append("text").text(s).classed("middle", true) + .attr("y", -75); + } + + function buildHierarchy(rows) { + var root = {"name": "root", "children": []}; + for (var i = 0; i < rows.length; i++) { + var row = rows[i]; + var m1 = +row[row.length-2]; + var m2 = +row[row.length-1]; + var parts = 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++) { + 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, "m1": m1, 'm2': m2}; + children.push(childNode); + } + } + } + function recurse(node){ + if (node.children){ + var m1 = 0; + var m2 = 0; + for (var i=0; i +
+ +
+ +{% endmacro %} + +{% macro viz_js(viz) %} +{% endmacro %} + +{% macro viz_css(viz) %} +{% endmacro %} + diff --git a/panoramix/viz.py b/panoramix/viz.py index edc96449e822d..76a3897d56b3b 100644 --- a/panoramix/viz.py +++ b/panoramix/viz.py @@ -784,6 +784,44 @@ def get_json_data(self): }) +class SunburstViz(BaseViz): + viz_type = "sunburst" + verbose_name = "Sunburst" + is_timeseries = False + template = 'panoramix/viz_sunburst.html' + js_files = [ + 'lib/d3.min.js', + 'widgets/viz_sunburst.js'] + css_files = ['widgets/viz_sunburst.css'] + fieldsets = ( + { + 'label': None, + 'fields': ( + 'viz_type', + ('since', 'until'), + 'groupby', + 'metric', 'secondary_metric', + 'limit', + ) + },) + + def get_df(self): + df = super(SunburstViz, self).get_df() + return df + + def get_json_data(self): + df = self.get_df() + # if m1 == m2 dupplicate the metric column + if self.form_data['metric'] == self.form_data['secondary_metric']: + df['dup'] = df[df.columns[-1]] + return df.to_json(orient="values") + + def query_obj(self): + qry = super(SunburstViz, self).query_obj() + qry['metrics'] = [ + self.form_data['metric'], self.form_data['secondary_metric']] + return qry + viz_types_list = [ TableViz, PivotTableViz, @@ -797,6 +835,7 @@ def get_json_data(self): MarkupViz, WordCloudViz, BigNumberViz, + SunburstViz, ] # This dict is used to viz_types = OrderedDict([(v.viz_type, v) for v in viz_types_list])