From da1192958b3a938f5b6d9154a1f1b8d23bbb7d0a Mon Sep 17 00:00:00 2001 From: chenryn Date: Wed, 2 Sep 2015 17:21:16 +0800 Subject: [PATCH 1/6] new visualize type: sankey --- package.json | 1 + .../public/editors/sankey.html | 7 + .../public/kbn_vislib_vis_types.js | 1 + .../kbn_vislib_vis_types/public/sankey.js | 55 +++++++ src/ui/public/Vis/Vis.js | 8 + src/ui/public/agg_response/index.js | 1 + src/ui/public/agg_response/sankey/sankey.js | 59 +++++++ .../vislib/lib/handler/handler_types.js | 1 + .../public/vislib/lib/handler/types/sankey.js | 18 +++ .../public/vislib/lib/layout/layout_types.js | 1 + .../lib/layout/splits/sankey/sankey_split.js | 48 ++++++ .../vislib/lib/layout/types/sankey_layout.js | 34 +++++ src/ui/public/vislib/styles/_sankey.less | 20 +++ src/ui/public/vislib/styles/main.less | 1 + .../vislib/visualizations/sankey_chart.js | 144 ++++++++++++++++++ .../public/vislib/visualizations/vis_types.js | 1 + .../public/vislib_vis_type/VislibVisType.js | 1 + .../public/vislib_vis_type/buildChartData.js | 4 + 18 files changed, 405 insertions(+) create mode 100644 src/plugins/kbn_vislib_vis_types/public/editors/sankey.html create mode 100644 src/plugins/kbn_vislib_vis_types/public/sankey.js create mode 100644 src/ui/public/agg_response/sankey/sankey.js create mode 100644 src/ui/public/vislib/lib/handler/types/sankey.js create mode 100644 src/ui/public/vislib/lib/layout/splits/sankey/sankey_split.js create mode 100644 src/ui/public/vislib/lib/layout/types/sankey_layout.js create mode 100644 src/ui/public/vislib/styles/_sankey.less create mode 100644 src/ui/public/vislib/visualizations/sankey_chart.js diff --git a/package.json b/package.json index b2862197b95bb7..40910c65980107 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "commander": "^2.8.1", "css-loader": "^0.15.1", "d3": "^3.5.6", + "d3-plugins-sankey": "1.2.1", "elasticsearch": "^5.0.0", "elasticsearch-browser": "^5.0.0", "expiry-js": "^0.1.7", diff --git a/src/plugins/kbn_vislib_vis_types/public/editors/sankey.html b/src/plugins/kbn_vislib_vis_types/public/editors/sankey.html new file mode 100644 index 00000000000000..06369349c4d6d2 --- /dev/null +++ b/src/plugins/kbn_vislib_vis_types/public/editors/sankey.html @@ -0,0 +1,7 @@ + +
+ +
+ diff --git a/src/plugins/kbn_vislib_vis_types/public/kbn_vislib_vis_types.js b/src/plugins/kbn_vislib_vis_types/public/kbn_vislib_vis_types.js index 6aa4e07a0230fd..5f6d1ea3e178a1 100644 --- a/src/plugins/kbn_vislib_vis_types/public/kbn_vislib_vis_types.js +++ b/src/plugins/kbn_vislib_vis_types/public/kbn_vislib_vis_types.js @@ -4,5 +4,6 @@ define(function (require) { visTypes.register(require('plugins/kbn_vislib_vis_types/line')); visTypes.register(require('plugins/kbn_vislib_vis_types/pie')); visTypes.register(require('plugins/kbn_vislib_vis_types/area')); + visTypes.register(require('plugins/kbn_vislib_vis_types/sankey')); visTypes.register(require('plugins/kbn_vislib_vis_types/tileMap')); }); diff --git a/src/plugins/kbn_vislib_vis_types/public/sankey.js b/src/plugins/kbn_vislib_vis_types/public/sankey.js new file mode 100644 index 00000000000000..62a91cb48e30b3 --- /dev/null +++ b/src/plugins/kbn_vislib_vis_types/public/sankey.js @@ -0,0 +1,55 @@ +define(function (require) { + return function HistogramVisType(Private) { + var VislibVisType = Private(require('ui/vislib_vis_type/VislibVisType')); + var Schemas = Private(require('ui/Vis/Schemas')); + var sankeyBuilder = Private(require('ui/agg_response/sankey/sankey')); + + return new VislibVisType({ + name: 'sankey', + title: 'Sankey chart', + icon: 'fa-sankey-chart', + description: 'Sankey charts are ideal for displaying the parts of some whole. For example, sales percentages by department.' + + 'Pro Tip: Sankey charts are best used sparingly, and with no more than 7 slices per sankey.', + params: { + defaults: { + shareYAxis: false, + isDonut: false + }, + editor: require('plugins/kbn_vislib_vis_types/editors/sankey.html') + }, + sankeyConverter: sankeyBuilder, + hierarchicalData: false, + schemas: new Schemas([ + { + group: 'metrics', + name: 'metric', + title: 'Slice Size', + min: 1, + aggFilter: ['sum', 'count', 'cardinality'], + defaults: [ + { schema: 'metric', type: 'count' } + ] + }, + { + group: 'buckets', + name: 'segment', + icon: 'fa fa-scissors', + title: 'Split Slices', + min: 0, + max: Infinity, + aggFilter: '!geohash_grid' + }, + { + group: 'buckets', + name: 'split', + icon: 'fa fa-th', + title: 'Split Chart', + mustBeFirst: true, + min: 0, + max: 1, + aggFilter: '!geohash_grid' + } + ]) + }); + }; +}); diff --git a/src/ui/public/Vis/Vis.js b/src/ui/public/Vis/Vis.js index cdfddd42464963..e79b75c09e7c0a 100644 --- a/src/ui/public/Vis/Vis.js +++ b/src/ui/public/Vis/Vis.js @@ -110,6 +110,14 @@ define(function (require) { } }; + Vis.prototype.isSankey = function () { + if (_.isFunction(this.type.sankeyConverter)) { + return true; + } else { + return !!this.type.sankeyConverter; + } + }; + Vis.prototype.hasSchemaAgg = function (schemaName, aggTypeName) { var aggs = this.aggs.bySchemaName[schemaName] || []; return aggs.some(function (agg) { diff --git a/src/ui/public/agg_response/index.js b/src/ui/public/agg_response/index.js index 1d9aeecc101245..7a5785d4c79bc4 100644 --- a/src/ui/public/agg_response/index.js +++ b/src/ui/public/agg_response/index.js @@ -4,6 +4,7 @@ define(function (require) { hierarchical: Private(require('ui/agg_response/hierarchical/build_hierarchical_data')), pointSeries: Private(require('ui/agg_response/point_series/point_series')), tabify: Private(require('ui/agg_response/tabify/tabify')), + sankey: Private(require('ui/agg_response/sankey/sankey')), geoJson: Private(require('ui/agg_response/geo_json/geo_json')) }; }; diff --git a/src/ui/public/agg_response/sankey/sankey.js b/src/ui/public/agg_response/sankey/sankey.js new file mode 100644 index 00000000000000..b5f51d8844d29b --- /dev/null +++ b/src/ui/public/agg_response/sankey/sankey.js @@ -0,0 +1,59 @@ +define(function (require) { + return function sankeyProvider(Private, Notifier) { + var _ = require('lodash'); + var arrayToLinkedList = require('ui/agg_response/hierarchical/_array_to_linked_list'); + var notify = new Notifier({ + location: 'Sankey chart response converter' + }); + var nodes = {}; + var links = {}; + var lastNode = -1; + + function processEntry(aggConfig, aggData, prevNode) { + _.each(aggData.buckets, function (b) { + if (isNaN(nodes[b.key])) { + nodes[b.key] = lastNode + 1; + lastNode = _.max(_.values(nodes)); + } + if (aggConfig._previous) { + var k = prevNode + 'sankeysplitchar' + nodes[b.key]; + if (isNaN(links[k])) { + links[k] = b.doc_count; + } else { + links[k] += b.doc_count; + } + } + if (aggConfig._next) { + processEntry(aggConfig._next, b[aggConfig._next.id], nodes[b.key]); + } + }); + } + + return function (vis, resp) { + + var buckets = vis.aggs.bySchemaGroup.buckets; + buckets = arrayToLinkedList(buckets); + var firstAgg = buckets[0]; + var aggData = resp.aggregations[firstAgg.id]; + + if (!firstAgg._next) { + notify.error('need more than one sub aggs'); + } + + processEntry(firstAgg, aggData, -1); + + var invertNodes = _.invert(nodes); + var chart = { + 'slices': { + 'nodes' : _.map(_.keys(invertNodes), function (k) { return {'name':invertNodes[k]}; }), + 'links' : _.map(_.keys(links), function (k) { + var s = k.split('sankeysplitchar'); + return {'source': parseInt(s[0]), 'target': parseInt(s[1]), 'value': links[k]}; + }) + } + }; + + return chart; + }; + }; +}); diff --git a/src/ui/public/vislib/lib/handler/handler_types.js b/src/ui/public/vislib/lib/handler/handler_types.js index 9d9c160285865b..75063dd7ff0835 100644 --- a/src/ui/public/vislib/lib/handler/handler_types.js +++ b/src/ui/public/vislib/lib/handler/handler_types.js @@ -12,6 +12,7 @@ define(function (require) { line: pointSeries.line, pie: Private(require('ui/vislib/lib/handler/types/pie')), area: pointSeries.area, + sankey: Private(require('ui/vislib/lib/handler/types/sankey')), tile_map: Private(require('ui/vislib/lib/handler/types/tile_map')) }; }; diff --git a/src/ui/public/vislib/lib/handler/types/sankey.js b/src/ui/public/vislib/lib/handler/types/sankey.js new file mode 100644 index 00000000000000..331e832b672ce6 --- /dev/null +++ b/src/ui/public/vislib/lib/handler/types/sankey.js @@ -0,0 +1,18 @@ +define(function (require) { + return function sankeyHandler(d3, Private) { + var _ = require('lodash'); + + var Handler = Private(require('ui/vislib/lib/handler/handler')); + var Data = Private(require('ui/vislib/lib/data')); + + return function (vis) { + var data = new Data(vis.data, vis._attr); + + var sankeyHandler = new Handler(vis, { + data: data + }); + + return sankeyHandler; + }; + }; +}); diff --git a/src/ui/public/vislib/lib/layout/layout_types.js b/src/ui/public/vislib/lib/layout/layout_types.js index ccaa776c4fe9a4..9037d082895cd4 100644 --- a/src/ui/public/vislib/lib/layout/layout_types.js +++ b/src/ui/public/vislib/lib/layout/layout_types.js @@ -14,6 +14,7 @@ define(function (require) { line: Private(require('ui/vislib/lib/layout/types/column_layout')), area: Private(require('ui/vislib/lib/layout/types/column_layout')), pie: Private(require('ui/vislib/lib/layout/types/pie_layout')), + sankey: Private(require('ui/vislib/lib/layout/types/sankey_layout')), tile_map: Private(require('ui/vislib/lib/layout/types/map_layout')) }; }; diff --git a/src/ui/public/vislib/lib/layout/splits/sankey/sankey_split.js b/src/ui/public/vislib/lib/layout/splits/sankey/sankey_split.js new file mode 100644 index 00000000000000..ab170dbb7ee010 --- /dev/null +++ b/src/ui/public/vislib/lib/layout/splits/sankey/sankey_split.js @@ -0,0 +1,48 @@ +define(function () { + return function ChartSplitFactory(d3) { + /* + * Adds div DOM elements to the `.chart-wrapper` element based on the data layout. + * For example, if the data has rows, it returns the same number of + * `.chart` elements as row objects. + */ + return function split(selection) { + selection.each(function (data) { + var div = d3.select(this) + .attr('class', function () { + if (data.rows) { + return 'chart-wrapper-row'; + } else if (data.columns) { + return 'chart-wrapper-column'; + } else { + return 'chart-wrapper'; + } + }); + var divClass; + + var charts = div.selectAll('charts') + .append('div') + .data(function (d) { + if (d.rows) { + divClass = 'chart-row'; + return d.rows; + } else if (d.columns) { + divClass = 'chart-column'; + return d.columns; + } else { + divClass = 'chart'; + return [d]; + } + }) + .enter() + .append('div') + .attr('class', function () { + return divClass; + }); + + if (!data.slices) { + charts.call(split); + } + }); + }; + }; +}); diff --git a/src/ui/public/vislib/lib/layout/types/sankey_layout.js b/src/ui/public/vislib/lib/layout/types/sankey_layout.js new file mode 100644 index 00000000000000..e85da08abe34b2 --- /dev/null +++ b/src/ui/public/vislib/lib/layout/types/sankey_layout.js @@ -0,0 +1,34 @@ +define(function (require) { + return function ColumnLayoutFactory(Private) { + var d3 = require('d3'); + var sankeySplit = + Private(require('ui/vislib/lib/layout/splits/sankey/sankey_split')); + return function (el, data) { + if (!el || !data) { + throw new Error('Both an el and data need to be specified'); + } + + return [ + { + parent: el, + type: 'div', + class: 'vis-wrapper', + datum: data, + children: [ + { + type: 'div', + class: 'vis-col-wrapper', + children: [ + { + type: 'div', + class: 'chart-wrapper', + splits: sankeySplit + } + ] + } + ] + } + ]; + }; + }; +}); diff --git a/src/ui/public/vislib/styles/_sankey.less b/src/ui/public/vislib/styles/_sankey.less new file mode 100644 index 00000000000000..96f0d4cca8f7f9 --- /dev/null +++ b/src/ui/public/vislib/styles/_sankey.less @@ -0,0 +1,20 @@ +.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/src/ui/public/vislib/styles/main.less b/src/ui/public/vislib/styles/main.less index f44fe6823d33c8..3dd1746fe7368a 100644 --- a/src/ui/public/vislib/styles/main.less +++ b/src/ui/public/vislib/styles/main.less @@ -7,3 +7,4 @@ @import "./_tooltip"; @import "./_tilemap"; @import "./_alerts"; +@import "./_sankey"; diff --git a/src/ui/public/vislib/visualizations/sankey_chart.js b/src/ui/public/vislib/visualizations/sankey_chart.js new file mode 100644 index 00000000000000..5beb6af297e5ac --- /dev/null +++ b/src/ui/public/vislib/visualizations/sankey_chart.js @@ -0,0 +1,144 @@ +define(function (require) { + return function SankeyChartFactory(Private) { + var d3 = require('d3'); + var _ = require('lodash'); + var $ = require('jquery'); + + var S = require('d3-plugins-sankey'); + var formatNumber = d3.format(',.0f'); + var format = function (d) { return formatNumber(d) + ' TWh'; }; + var color = d3.scale.category20(); + + var Chart = Private(require('ui/vislib/visualizations/_chart')); + var errors = require('ui/errors'); + + /** + * Sankey Chart Visualization + * + * @class SankeyChart + * @constructor + * @extends Chart + * @param handler {Object} Reference to the Handler Class Constructor + * @param el {HTMLElement} HTML element to which the chart will be appended + * @param chartData {Object} Elasticsearch query results for this specific chart + */ + _.class(SankeyChart).inherits(Chart); + function SankeyChart(handler, chartEl, chartData) { + if (!(this instanceof SankeyChart)) { + return new SankeyChart(handler, chartEl, chartData); + } + SankeyChart.Super.apply(this, arguments); + + var charts = this.handler.data.getVisData(); + } + + + SankeyChart.prototype._validateContainerSize = function (width, height) { + var minWidth = 20; + var minHeight = 20; + + if (width <= minWidth || height <= minHeight) { + throw new errors.ContainerTooSmall(); + } + }; + + /** + * Renders d3 visualization + * + * @method draw + * @returns {Function} Creates the sankey chart + */ + SankeyChart.prototype.draw = function () { + var self = this; + var $elem = $(this.chartEl); + var margin = this._attr.margin; + var elWidth = this._attr.width = $elem.width(); + var elHeight = this._attr.height = $elem.height(); + var width; + var height; + var div; + var svg; + + return function (selection) { + selection.each(function (data) { + var energy = data.slices; + div = d3.select(this); + width = elWidth - margin.left - margin.right; + height = elHeight - margin.top - margin.bottom; + + if (!energy.nodes.length) return; + + self._validateContainerSize(width, height); + + svg = div.append('svg') + .attr('width', elWidth) + .attr('height', elHeight) + .append('g') + .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); + + var sankey = d3.sankey() + .nodeWidth(15) + .nodePadding(10) + .size([width, height]); + + var path = sankey.link(); + + sankey + .nodes(energy.nodes) + .links(energy.links) + .layout(32); + + var link = svg.append('g').selectAll('.link') + .data(energy.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(energy.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); + } + + return svg; + }); + }; + }; + + return SankeyChart; + }; +}); diff --git a/src/ui/public/vislib/visualizations/vis_types.js b/src/ui/public/vislib/visualizations/vis_types.js index cc238529043049..1a8ee50eeac41a 100644 --- a/src/ui/public/vislib/visualizations/vis_types.js +++ b/src/ui/public/vislib/visualizations/vis_types.js @@ -14,6 +14,7 @@ define(function (require) { pie: Private(require('ui/vislib/visualizations/pie_chart')), line: Private(require('ui/vislib/visualizations/line_chart')), area: Private(require('ui/vislib/visualizations/area_chart')), + sankey: Private(require('ui/vislib/visualizations/sankey_chart')), tile_map: Private(require('ui/vislib/visualizations/tile_map')) }; }; diff --git a/src/ui/public/vislib_vis_type/VislibVisType.js b/src/ui/public/vislib_vis_type/VislibVisType.js index 32dbb8503d99d8..9d41701ba66e0f 100644 --- a/src/ui/public/vislib_vis_type/VislibVisType.js +++ b/src/ui/public/vislib_vis_type/VislibVisType.js @@ -23,6 +23,7 @@ define(function (require) { } this.listeners = opts.listeners || {}; + this.sankeyConverter = opts.sankeyConverter || false; } VislibVisType.prototype.createRenderbot = function (vis, $el) { diff --git a/src/ui/public/vislib_vis_type/buildChartData.js b/src/ui/public/vislib_vis_type/buildChartData.js index 29f956f9e9693c..458e0a45aba707 100644 --- a/src/ui/public/vislib_vis_type/buildChartData.js +++ b/src/ui/public/vislib_vis_type/buildChartData.js @@ -11,6 +11,10 @@ define(function (require) { return aggResponse.hierarchical(vis, esResponse); } + if (vis.isSankey()) { + return aggResponse.sankey(vis, esResponse); + } + var tableGroup = aggResponse.tabify(vis, esResponse, { canSplit: true, asAggConfigResults: true From 88480f834e193e8d7065fbb9e7a432ac98ea76b1 Mon Sep 17 00:00:00 2001 From: chenryn Date: Tue, 8 Sep 2015 19:10:44 +0800 Subject: [PATCH 2/6] remove unused argument upgraded from 4.1.1 --- src/ui/public/agg_response/sankey/sankey.js | 4 ++++ src/ui/public/vislib/lib/handler/types/sankey.js | 4 +--- src/ui/public/vislib/lib/layout/splits/sankey/sankey_split.js | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/ui/public/agg_response/sankey/sankey.js b/src/ui/public/agg_response/sankey/sankey.js index b5f51d8844d29b..ace657177f6ad8 100644 --- a/src/ui/public/agg_response/sankey/sankey.js +++ b/src/ui/public/agg_response/sankey/sankey.js @@ -33,6 +33,10 @@ define(function (require) { var buckets = vis.aggs.bySchemaGroup.buckets; buckets = arrayToLinkedList(buckets); + if (!buckets) { + return {'slices':{'nodes':[],'links':[]}}; + } + var firstAgg = buckets[0]; var aggData = resp.aggregations[firstAgg.id]; diff --git a/src/ui/public/vislib/lib/handler/types/sankey.js b/src/ui/public/vislib/lib/handler/types/sankey.js index 331e832b672ce6..96c37d1cc3fa67 100644 --- a/src/ui/public/vislib/lib/handler/types/sankey.js +++ b/src/ui/public/vislib/lib/handler/types/sankey.js @@ -1,7 +1,5 @@ define(function (require) { - return function sankeyHandler(d3, Private) { - var _ = require('lodash'); - + return function sankeyHandler(Private) { var Handler = Private(require('ui/vislib/lib/handler/handler')); var Data = Private(require('ui/vislib/lib/data')); diff --git a/src/ui/public/vislib/lib/layout/splits/sankey/sankey_split.js b/src/ui/public/vislib/lib/layout/splits/sankey/sankey_split.js index ab170dbb7ee010..32969a5ef7c606 100644 --- a/src/ui/public/vislib/lib/layout/splits/sankey/sankey_split.js +++ b/src/ui/public/vislib/lib/layout/splits/sankey/sankey_split.js @@ -1,5 +1,7 @@ define(function () { - return function ChartSplitFactory(d3) { + return function ChartSplitFactory() { + var d3 = require('d3'); + /* * Adds div DOM elements to the `.chart-wrapper` element based on the data layout. * For example, if the data has rows, it returns the same number of From 80eedb70d06efb9c68fb98b2b093278b9b69ca35 Mon Sep 17 00:00:00 2001 From: chenryn Date: Wed, 9 Sep 2015 00:39:12 +0800 Subject: [PATCH 3/6] add testing of sankey in agg_response and vislib --- src/ui/public/Vis/Vis.js | 2 +- src/ui/public/Vis/__tests__/_Vis.js | 19 +++++ .../agg_response/sankey/__tests__/sankey.js | 75 +++++++++++++++++++ .../__tests__/visualizations/sankey_chart.js | 59 +++++++++++++++ .../__tests__/_buildChartData.js | 4 + 5 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 src/ui/public/agg_response/sankey/__tests__/sankey.js create mode 100644 src/ui/public/vislib/__tests__/visualizations/sankey_chart.js diff --git a/src/ui/public/Vis/Vis.js b/src/ui/public/Vis/Vis.js index 707c2fc2ab4903..9794c745312feb 100644 --- a/src/ui/public/Vis/Vis.js +++ b/src/ui/public/Vis/Vis.js @@ -117,7 +117,7 @@ define(function (require) { if (_.isFunction(this.type.sankeyConverter)) { return true; } else { - return !!this.type.sankeyConverter; + return false; } }; diff --git a/src/ui/public/Vis/__tests__/_Vis.js b/src/ui/public/Vis/__tests__/_Vis.js index f72e69e29af9d4..01868027c7ed2f 100644 --- a/src/ui/public/Vis/__tests__/_Vis.js +++ b/src/ui/public/Vis/__tests__/_Vis.js @@ -104,4 +104,23 @@ describe('Vis Class', function () { }); }); + describe('isSankey()', function () { + it('should return true for sankey vis', function () { + var stateFixture = { + type: 'sankey', + aggs: [ + { type: 'count', schema: 'metric' }, + { type: 'terms', schema: 'segment', params: { field: 'extension' }}, + { type: 'terms', schema: 'segment', params: { field: 'machine.os' }}, + { type: 'terms', schema: 'segment', params: { field: 'geo.src' }} + ] + }; + var vis = new Vis(indexPattern, stateFixture); + expect(vis.isSankey()).to.be(true); + }); + it('should return false for non-sankey vis (like pie)', function () { + expect(vis.isSankey()).to.be(false); + }); + }); + }); diff --git a/src/ui/public/agg_response/sankey/__tests__/sankey.js b/src/ui/public/agg_response/sankey/__tests__/sankey.js new file mode 100644 index 00000000000000..4ab3805ca089cf --- /dev/null +++ b/src/ui/public/agg_response/sankey/__tests__/sankey.js @@ -0,0 +1,75 @@ + +var _ = require('lodash'); +var fixtures = require('fixtures/fake_hierarchical_data'); +var sinon = require('auto-release-sinon'); +var expect = require('expect.js'); +var ngMock = require('ngMock'); + +var Vis; +var Notifier; +var AggConfigs; +var indexPattern; +var buildSankey; + +describe('sankey', function () { + + beforeEach(ngMock.module('kibana')); + beforeEach(ngMock.inject(function (Private, $injector) { + Notifier = $injector.get('Notifier'); + sinon.stub(Notifier.prototype, 'error'); + + Vis = Private(require('ui/Vis')); + AggConfigs = Private(require('ui/Vis/AggConfigs')); + indexPattern = Private(require('fixtures/stubbed_logstash_index_pattern')); + buildSankey = Private(require('ui/agg_response/sankey/sankey')); + })); + + describe('threeTermBuckets', function () { + var vis; + var results; + + beforeEach(function () { + var id = 1; + vis = new Vis(indexPattern, { + type: 'sankey', + aggs: [ + { type: 'count', schema: 'metric' }, + { type: 'terms', schema: 'segment', params: { field: 'extension' }}, + { type: 'terms', schema: 'segment', params: { field: 'machine.os' }}, + { type: 'terms', schema: 'segment', params: { field: 'geo.src' }} + ] + }); + // We need to set the aggs to a known value. + _.each(vis.aggs, function (agg) { agg.id = 'agg_' + id++; }); + results = buildSankey(vis, fixtures.threeTermBuckets); + }); + + it('should have nodes and links attributes for the results', function () { + expect(results).to.have.property('slices'); + expect(results.slices).to.have.property('nodes'); + expect(results.slices).to.have.property('links'); + }); + + it('should have name attributes for the nodes array', function () { + expect(results.slices.nodes).to.have.length(11); + _.each(results.slices.nodes, function (item) { + expect(item).to.have.property('name'); + }); + expect(results.slices.nodes[0].name).to.equal('png'); + }); + + it('should have source, target and value attributes for the links array', function () { + expect(results.slices.links).to.have.length(16); + _.each(results.slices.links, function (item) { + expect(item).to.have.property('source'); + expect(item).to.have.property('target'); + expect(item).to.have.property('value'); + }); + expect(results.slices.links[0].source).to.equal(0); + expect(results.slices.links[0].target).to.equal(1); + expect(results.slices.links[0].value).to.equal(10); + }); + + }); + +}); diff --git a/src/ui/public/vislib/__tests__/visualizations/sankey_chart.js b/src/ui/public/vislib/__tests__/visualizations/sankey_chart.js new file mode 100644 index 00000000000000..4b165e7cc56d3a --- /dev/null +++ b/src/ui/public/vislib/__tests__/visualizations/sankey_chart.js @@ -0,0 +1,59 @@ +var d3 = require('d3'); +var angular = require('angular'); +var expect = require('expect.js'); +var ngMock = require('ngMock'); +var _ = require('lodash'); +var $ = require('jquery'); +var fixtures = require('fixtures/fake_hierarchical_data'); + +var sliceAgg = [ + { type: 'count', schema: 'metric' }, + { type: 'terms', schema: 'segment', params: { field: 'extension' }}, + { type: 'terms', schema: 'segment', params: { field: 'machine.os' }}, + { type: 'terms', schema: 'segment', params: { field: 'geo.src' }} +]; + +describe('Vislib SankeyChart Class Test Suite for slice data', function () { + var visLibParams = { + type: 'sankey' + }; + var vis; + var Vis; + var indexPattern; + var buildSankey; + var data; + + beforeEach(ngMock.module('kibana')); + beforeEach(ngMock.inject(function (Private) { + vis = Private(require('fixtures/vislib/_vis_fixture'))(visLibParams); + Vis = Private(require('ui/Vis')); + indexPattern = Private(require('fixtures/stubbed_logstash_index_pattern')); + buildSankey = Private(require('ui/agg_response/sankey/sankey')); + + var id = 1; + var stubVis = new Vis(indexPattern, { + type: 'sankey', + aggs: sliceAgg + }); + + _.each(stubVis.aggs, function (agg) { agg.id = 'agg_' + id++; }); + + data = buildSankey(stubVis, fixtures.threeTermBuckets); + + vis.render(data); + })); + + afterEach(function () { + $(vis.el).remove(); + vis = null; + }); + + describe('draw method', function () { + it('should return a function', function () { + vis.handler.charts.forEach(function (chart) { + expect(_.isFunction(chart.draw())).to.be(true); + }); + }); + }); + +}); diff --git a/src/ui/public/vislib_vis_type/__tests__/_buildChartData.js b/src/ui/public/vislib_vis_type/__tests__/_buildChartData.js index a623a83d08e9a9..2426128fc440f1 100644 --- a/src/ui/public/vislib_vis_type/__tests__/_buildChartData.js +++ b/src/ui/public/vislib_vis_type/__tests__/_buildChartData.js @@ -24,6 +24,7 @@ describe('renderbot#buildChartData', function () { var football = {}; var renderbot = { vis: { + isSankey: _.constant(false), isHierarchical: _.constant(true) } }; @@ -40,6 +41,7 @@ describe('renderbot#buildChartData', function () { it('calls tabify to simplify the data into a table', function () { var renderbot = { vis: { + isSankey: _.constant(false), isHierarchical: _.constant(false) } }; @@ -56,6 +58,7 @@ describe('renderbot#buildChartData', function () { var chart = { hits: 1, rows: [], columns: [] }; var renderbot = { vis: { + isSankey: _.constant(false), isHierarchical: _.constant(false), type: { responseConverter: _.constant(chart) @@ -77,6 +80,7 @@ describe('renderbot#buildChartData', function () { var renderbot = { vis: { + isSankey: _.constant(false), isHierarchical: _.constant(false), type: { responseConverter: converter From 617b3e9a1e1b76b8f025b77e08cdfde9b80e3f4f Mon Sep 17 00:00:00 2001 From: chenryn Date: Fri, 11 Sep 2015 11:41:07 +0800 Subject: [PATCH 4/6] use metric.getValue() instead of doc_count --- src/plugins/kbn_vislib_vis_types/public/sankey.js | 4 ++-- src/ui/public/agg_response/sankey/sankey.js | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/plugins/kbn_vislib_vis_types/public/sankey.js b/src/plugins/kbn_vislib_vis_types/public/sankey.js index 62a91cb48e30b3..ade07eedaff883 100644 --- a/src/plugins/kbn_vislib_vis_types/public/sankey.js +++ b/src/plugins/kbn_vislib_vis_types/public/sankey.js @@ -8,7 +8,7 @@ define(function (require) { name: 'sankey', title: 'Sankey chart', icon: 'fa-sankey-chart', - description: 'Sankey charts are ideal for displaying the parts of some whole. For example, sales percentages by department.' + + description: 'Sankey charts are ideal for displaying the material, energy and cost flows.' + 'Pro Tip: Sankey charts are best used sparingly, and with no more than 7 slices per sankey.', params: { defaults: { @@ -25,7 +25,7 @@ define(function (require) { name: 'metric', title: 'Slice Size', min: 1, - aggFilter: ['sum', 'count', 'cardinality'], + aggFilter: ['sum', 'count', 'cardinality', 'min', 'max', 'avg'], defaults: [ { schema: 'metric', type: 'count' } ] diff --git a/src/ui/public/agg_response/sankey/sankey.js b/src/ui/public/agg_response/sankey/sankey.js index ace657177f6ad8..9f07cf5eae3bd7 100644 --- a/src/ui/public/agg_response/sankey/sankey.js +++ b/src/ui/public/agg_response/sankey/sankey.js @@ -9,7 +9,7 @@ define(function (require) { var links = {}; var lastNode = -1; - function processEntry(aggConfig, aggData, prevNode) { + function processEntry(aggConfig, metric, aggData, prevNode) { _.each(aggData.buckets, function (b) { if (isNaN(nodes[b.key])) { nodes[b.key] = lastNode + 1; @@ -18,19 +18,20 @@ define(function (require) { if (aggConfig._previous) { var k = prevNode + 'sankeysplitchar' + nodes[b.key]; if (isNaN(links[k])) { - links[k] = b.doc_count; + links[k] = metric.getValue(b); } else { - links[k] += b.doc_count; + links[k] += metric.getValue(b); } } if (aggConfig._next) { - processEntry(aggConfig._next, b[aggConfig._next.id], nodes[b.key]); + processEntry(aggConfig._next, metric, b[aggConfig._next.id], nodes[b.key]); } }); } return function (vis, resp) { + var metric = vis.aggs.bySchemaGroup.metrics[0]; var buckets = vis.aggs.bySchemaGroup.buckets; buckets = arrayToLinkedList(buckets); if (!buckets) { @@ -44,7 +45,7 @@ define(function (require) { notify.error('need more than one sub aggs'); } - processEntry(firstAgg, aggData, -1); + processEntry(firstAgg, metric, aggData, -1); var invertNodes = _.invert(nodes); var chart = { From ac6e0eddb0f992fefc6c0ab75905cb7f9dbf0bfe Mon Sep 17 00:00:00 2001 From: chenryn Date: Fri, 11 Sep 2015 13:26:55 +0800 Subject: [PATCH 5/6] add Data.prototype.getSankeyColorFunc and use it in sankey chart --- src/ui/public/vislib/lib/data.js | 21 +++++++++++++++++++ .../vislib/visualizations/sankey_chart.js | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/ui/public/vislib/lib/data.js b/src/ui/public/vislib/lib/data.js index 276c1c3f913708..a45967374597c3 100644 --- a/src/ui/public/vislib/lib/data.js +++ b/src/ui/public/vislib/lib/data.js @@ -661,6 +661,27 @@ define(function (require) { })); }; + /** + * Returns a function that does color lookup on names for sankey charts + * + * @method getSankeyColorFunc + * @returns {Function} Performs lookup on string and returns hex color + */ + Data.prototype.getSankeyColorFunc = function () { + var data = this.getVisData(); + var names = []; + + _.forEach(data, function (obj) { + obj.slices = this._removeZeroSlices(obj.slices); + + _.forEach(obj.slices.nodes, function (node) { + names.push(node.name); + }); + }); + + return color(names); + }; + /** * ensure that the datas ordered property has a min and max * if the data represents an ordered date range. diff --git a/src/ui/public/vislib/visualizations/sankey_chart.js b/src/ui/public/vislib/visualizations/sankey_chart.js index 5beb6af297e5ac..b53c80c045f562 100644 --- a/src/ui/public/vislib/visualizations/sankey_chart.js +++ b/src/ui/public/vislib/visualizations/sankey_chart.js @@ -7,7 +7,6 @@ define(function (require) { var S = require('d3-plugins-sankey'); var formatNumber = d3.format(',.0f'); var format = function (d) { return formatNumber(d) + ' TWh'; }; - var color = d3.scale.category20(); var Chart = Private(require('ui/vislib/visualizations/_chart')); var errors = require('ui/errors'); @@ -54,6 +53,7 @@ define(function (require) { var margin = this._attr.margin; var elWidth = this._attr.width = $elem.width(); var elHeight = this._attr.height = $elem.height(); + var color = this.handler.data.getSankeyColorFunc(); var width; var height; var div; @@ -112,7 +112,7 @@ define(function (require) { 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('fill', function (d) { return d.color = color(d.name); }) .style('stroke', function (d) { return d3.rgb(d.color).darker(2); }) .append('title') .text(function (d) { return d.name + '\n' + format(d.value); }); From 5e1d5d3ab4a45406c47cbf24075781e9bcf3b1b5 Mon Sep 17 00:00:00 2001 From: chenryn Date: Fri, 11 Sep 2015 19:40:57 +0800 Subject: [PATCH 6/6] no need `_removeZeroSlices` --- src/ui/public/vislib/lib/data.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ui/public/vislib/lib/data.js b/src/ui/public/vislib/lib/data.js index a45967374597c3..40f5fb17813d25 100644 --- a/src/ui/public/vislib/lib/data.js +++ b/src/ui/public/vislib/lib/data.js @@ -672,8 +672,6 @@ define(function (require) { var names = []; _.forEach(data, function (obj) { - obj.slices = this._removeZeroSlices(obj.slices); - _.forEach(obj.slices.nodes, function (node) { names.push(node.name); });