From 0b0a0951639816abecfd2a5b897dd7799cdc3e99 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 9 Dec 2015 22:37:08 -0800 Subject: [PATCH] Adding sankey diagrams --- panoramix/static/lib/d3-sankey.js | 294 ++++++++++++++++++ panoramix/static/widgets/viz_sankey.css | 29 ++ panoramix/static/widgets/viz_sankey.js | 109 +++++++ panoramix/templates/panoramix/viz_sankey.html | 10 + panoramix/viz.py | 38 +++ 5 files changed, 480 insertions(+) create mode 100644 panoramix/static/lib/d3-sankey.js create mode 100644 panoramix/static/widgets/viz_sankey.css create mode 100644 panoramix/static/widgets/viz_sankey.js create mode 100644 panoramix/templates/panoramix/viz_sankey.html diff --git a/panoramix/static/lib/d3-sankey.js b/panoramix/static/lib/d3-sankey.js new file mode 100644 index 0000000000..abe137b211 --- /dev/null +++ b/panoramix/static/lib/d3-sankey.js @@ -0,0 +1,294 @@ +d3.sankey = function() { + var sankey = {}, + nodeWidth = 24, + nodePadding = 8, + size = [1, 1], + nodes = [], + links = []; + + sankey.nodeWidth = function(_) { + if (!arguments.length) return nodeWidth; + nodeWidth = +_; + return sankey; + }; + + sankey.nodePadding = function(_) { + if (!arguments.length) return nodePadding; + nodePadding = +_; + return sankey; + }; + + sankey.nodes = function(_) { + if (!arguments.length) return nodes; + nodes = _; + return sankey; + }; + + sankey.links = function(_) { + if (!arguments.length) return links; + links = _; + return sankey; + }; + + sankey.size = function(_) { + if (!arguments.length) return size; + size = _; + return sankey; + }; + + sankey.layout = function(iterations) { + computeNodeLinks(); + computeNodeValues(); + computeNodeBreadths(); + computeNodeDepths(iterations); + computeLinkDepths(); + return sankey; + }; + + sankey.relayout = function() { + computeLinkDepths(); + return sankey; + }; + + sankey.link = function() { + var curvature = .5; + + function link(d) { + var x0 = d.source.x + d.source.dx, + x1 = d.target.x, + xi = d3.interpolateNumber(x0, x1), + x2 = xi(curvature), + x3 = xi(1 - curvature), + y0 = d.source.y + d.sy + d.dy / 2, + y1 = d.target.y + d.ty + d.dy / 2; + return "M" + x0 + "," + y0 + + "C" + x2 + "," + y0 + + " " + x3 + "," + y1 + + " " + x1 + "," + y1; + } + + link.curvature = function(_) { + if (!arguments.length) return curvature; + curvature = +_; + return link; + }; + + return link; + }; + + // Populate the sourceLinks and targetLinks for each node. + // Also, if the source and target are not objects, assume they are indices. + function computeNodeLinks() { + nodes.forEach(function(node) { + node.sourceLinks = []; + node.targetLinks = []; + }); + links.forEach(function(link) { + var source = link.source, + target = link.target; + if (typeof source === "number") source = link.source = nodes[link.source]; + if (typeof target === "number") target = link.target = nodes[link.target]; + source.sourceLinks.push(link); + target.targetLinks.push(link); + }); + } + + // Compute the value (size) of each node by summing the associated links. + function computeNodeValues() { + nodes.forEach(function(node) { + node.value = Math.max( + d3.sum(node.sourceLinks, value), + d3.sum(node.targetLinks, value) + ); + }); + } + + // Iteratively assign the breadth (x-position) for each node. + // Nodes are assigned the maximum breadth of incoming neighbors plus one; + // nodes with no incoming links are assigned breadth zero, while + // nodes with no outgoing links are assigned the maximum breadth. + function computeNodeBreadths() { + var remainingNodes = nodes, + nextNodes, + x = 0; + + while (remainingNodes.length) { + nextNodes = []; + remainingNodes.forEach(function(node) { + node.x = x; + node.dx = nodeWidth; + node.sourceLinks.forEach(function(link) { + if (nextNodes.indexOf(link.target) < 0) { + nextNodes.push(link.target); + } + }); + }); + remainingNodes = nextNodes; + ++x; + } + + // + moveSinksRight(x); + scaleNodeBreadths((size[0] - nodeWidth) / (x - 1)); + } + + function moveSourcesRight() { + nodes.forEach(function(node) { + if (!node.targetLinks.length) { + node.x = d3.min(node.sourceLinks, function(d) { return d.target.x; }) - 1; + } + }); + } + + function moveSinksRight(x) { + nodes.forEach(function(node) { + if (!node.sourceLinks.length) { + node.x = x - 1; + } + }); + } + + function scaleNodeBreadths(kx) { + nodes.forEach(function(node) { + node.x *= kx; + }); + } + + function computeNodeDepths(iterations) { + var nodesByBreadth = d3.nest() + .key(function(d) { return d.x; }) + .sortKeys(d3.ascending) + .entries(nodes) + .map(function(d) { return d.values; }); + + // + initializeNodeDepth(); + resolveCollisions(); + for (var alpha = 1; iterations > 0; --iterations) { + relaxRightToLeft(alpha *= .99); + resolveCollisions(); + relaxLeftToRight(alpha); + resolveCollisions(); + } + + function initializeNodeDepth() { + var ky = d3.min(nodesByBreadth, function(nodes) { + return (size[1] - (nodes.length - 1) * nodePadding) / d3.sum(nodes, value); + }); + + nodesByBreadth.forEach(function(nodes) { + nodes.forEach(function(node, i) { + node.y = i; + node.dy = node.value * ky; + }); + }); + + links.forEach(function(link) { + link.dy = link.value * ky; + }); + } + + function relaxLeftToRight(alpha) { + nodesByBreadth.forEach(function(nodes, breadth) { + nodes.forEach(function(node) { + if (node.targetLinks.length) { + var y = d3.sum(node.targetLinks, weightedSource) / d3.sum(node.targetLinks, value); + node.y += (y - center(node)) * alpha; + } + }); + }); + + function weightedSource(link) { + return center(link.source) * link.value; + } + } + + function relaxRightToLeft(alpha) { + nodesByBreadth.slice().reverse().forEach(function(nodes) { + nodes.forEach(function(node) { + if (node.sourceLinks.length) { + var y = d3.sum(node.sourceLinks, weightedTarget) / d3.sum(node.sourceLinks, value); + node.y += (y - center(node)) * alpha; + } + }); + }); + + function weightedTarget(link) { + return center(link.target) * link.value; + } + } + + function resolveCollisions() { + nodesByBreadth.forEach(function(nodes) { + var node, + dy, + y0 = 0, + n = nodes.length, + i; + + // Push any overlapping nodes down. + nodes.sort(ascendingDepth); + for (i = 0; i < n; ++i) { + node = nodes[i]; + dy = y0 - node.y; + if (dy > 0) node.y += dy; + y0 = node.y + node.dy + nodePadding; + } + + // If the bottommost node goes outside the bounds, push it back up. + dy = y0 - nodePadding - size[1]; + if (dy > 0) { + y0 = node.y -= dy; + + // Push any overlapping nodes back up. + for (i = n - 2; i >= 0; --i) { + node = nodes[i]; + dy = node.y + node.dy + nodePadding - y0; + if (dy > 0) node.y -= dy; + y0 = node.y; + } + } + }); + } + + function ascendingDepth(a, b) { + return a.y - b.y; + } + } + + function computeLinkDepths() { + nodes.forEach(function(node) { + node.sourceLinks.sort(ascendingTargetDepth); + node.targetLinks.sort(ascendingSourceDepth); + }); + nodes.forEach(function(node) { + var sy = 0, ty = 0; + node.sourceLinks.forEach(function(link) { + link.sy = sy; + sy += link.dy; + }); + node.targetLinks.forEach(function(link) { + link.ty = ty; + ty += link.dy; + }); + }); + + function ascendingSourceDepth(a, b) { + return a.source.y - b.source.y; + } + + function ascendingTargetDepth(a, b) { + return a.target.y - b.target.y; + } + } + + function center(node) { + return node.y + node.dy / 2; + } + + function value(link) { + return link.value; + } + + return sankey; +}; diff --git a/panoramix/static/widgets/viz_sankey.css b/panoramix/static/widgets/viz_sankey.css new file mode 100644 index 0000000000..f18302e6ab --- /dev/null +++ b/panoramix/static/widgets/viz_sankey.css @@ -0,0 +1,29 @@ +#chart { + height: 100%; +} +div.token { + height: 100%; +} + +.node rect { + cursor: move; + fill-opacity: .9; + shape-rendering: crispEdges; +} + +.node text { + pointer-events: none; + text-shadow: 0 1px 0 #fff; +} + +.link { + fill: none; + stroke: #000; + stroke-opacity: .2; +} + +.link:hover { + stroke-opacity: .5; +} + + diff --git a/panoramix/static/widgets/viz_sankey.js b/panoramix/static/widgets/viz_sankey.js new file mode 100644 index 0000000000..43445f3f3f --- /dev/null +++ b/panoramix/static/widgets/viz_sankey.js @@ -0,0 +1,109 @@ +function viz_sankey(data_attribute) { + var token = d3.select('#' + data_attribute.token); + var div = token.select("#chart"); + var xy = div.node().getBoundingClientRect(); + var width = xy.width; + var height = xy.height - 25; + + var render = function(done) { + var margin = {top: 1, right: 1, bottom: 6, left: 1}; + width = width - margin.left - margin.right; + height = height - margin.top - margin.bottom; + + var formatNumber = d3.format(",.0f"), + format = function(d) { return formatNumber(d) + " TWh"; }, + color = d3.scale.category20(); + + var svg = token.select("#chart").append("svg") + .attr("width", width + margin.left + margin.right) + .attr("height", height + margin.top + margin.bottom) + .append("g") + .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + var sankey = d3.sankey() + .nodeWidth(15) + .nodePadding(10) + .size([width, height]); + + var path = sankey.link(); + + d3.json(data_attribute.json_endpoint, function(error, json) { + if (error != null){ + var err = '
' + error.responseText + '
'; + token.html(err); + done(); + return ''; + } + links = json.data; + var nodes = {}; + // Compute the distinct nodes from the links. + links.forEach(function(link) { + link.source = nodes[link.source] || + (nodes[link.source] = {name: link.source}); + link.target = nodes[link.target] || + (nodes[link.target] = {name: link.target}); + link.value = +link.value; + var target_name = link.target.name; + var source_name = link.source.name; + }); + nodes = d3.values(nodes); + + sankey + .nodes(nodes) + .links(links) + .layout(32); + + var link = svg.append("g").selectAll(".link") + .data(links) + .enter().append("path") + .attr("class", "link") + .attr("d", path) + .style("stroke-width", function(d) { return Math.max(1, d.dy); }) + .sort(function(a, b) { return b.dy - a.dy; }); + + link.append("title") + .text(function(d) { return d.source.name + " → " + d.target.name + "\n" + format(d.value); }); + + var node = svg.append("g").selectAll(".node") + .data(nodes) + .enter().append("g") + .attr("class", "node") + .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; }) + .call(d3.behavior.drag() + .origin(function(d) { return d; }) + .on("dragstart", function() { this.parentNode.appendChild(this); }) + .on("drag", dragmove)); + + node.append("rect") + .attr("height", function(d) { return d.dy; }) + .attr("width", sankey.nodeWidth()) + .style("fill", function(d) { return d.color = color(d.name.replace(/ .*/, "")); }) + .style("stroke", function(d) { return d3.rgb(d.color).darker(2); }) + .append("title") + .text(function(d) { return d.name + "\n" + format(d.value); }); + + node.append("text") + .attr("x", -6) + .attr("y", function(d) { return d.dy / 2; }) + .attr("dy", ".35em") + .attr("text-anchor", "end") + .attr("transform", null) + .text(function(d) { return d.name; }) + .filter(function(d) { return d.x < width / 2; }) + .attr("x", 6 + sankey.nodeWidth()) + .attr("text-anchor", "start"); + + function dragmove(d) { + d3.select(this).attr("transform", "translate(" + d.x + "," + (d.y = Math.max(0, Math.min(height - d.dy, d3.event.y))) + ")"); + sankey.relayout(); + link.attr("d", path); + } + token.select("img.loading").remove(); + done(); + }); + } + return { + render: render, + resize: render, + }; +} +px.registerWidget('sankey', viz_sankey); diff --git a/panoramix/templates/panoramix/viz_sankey.html b/panoramix/templates/panoramix/viz_sankey.html new file mode 100644 index 0000000000..0c95793164 --- /dev/null +++ b/panoramix/templates/panoramix/viz_sankey.html @@ -0,0 +1,10 @@ +{% macro viz_html(viz) %} +
+{% endmacro %} + +{% macro viz_js(viz) %} +{% endmacro %} + +{% macro viz_css(viz) %} +{% endmacro %} + diff --git a/panoramix/viz.py b/panoramix/viz.py index e153d83a1b..27a3bf111f 100644 --- a/panoramix/viz.py +++ b/panoramix/viz.py @@ -878,6 +878,43 @@ class SunburstViz(BaseViz): self.form_data['metric'], self.form_data['secondary_metric']] return qry + +class SankeyViz(BaseViz): + viz_type = "sankey" + verbose_name = "Sankey" + is_timeseries = False + template = 'panoramix/viz_sankey.html' + js_files = [ + 'lib/d3.min.js', + 'lib/d3-sankey.js', + 'widgets/viz_sankey.js'] + css_files = ['widgets/viz_sankey.css'] + fieldsets = ( + { + 'label': None, + 'fields': ( + 'granularity', + ('since', 'until'), + 'groupby', + 'metric', + 'row_limit', + ) + },) + form_overrides = {} + + def query_obj(self): + qry = super(SankeyViz, self).query_obj() + qry['metrics'] = [ + self.form_data['metric']] + return qry + + def get_json_data(self): + df = self.get_df() + df.columns = ['source', 'target', 'value'] + d = df.to_dict(orient='records') + return dumps(d) + + class DirectedForceViz(BaseViz): viz_type = "directed_force" verbose_name = "Directed Force Layout" @@ -939,6 +976,7 @@ viz_types_list = [ BigNumberViz, SunburstViz, DirectedForceViz, + SankeyViz, ] # This dict is used to viz_types = OrderedDict([(v.viz_type, v) for v in viz_types_list])