diff options
| author | Mathieu Duckerts-Antoine <dam@odoo.com> | 2019-02-21 14:29:26 +0000 |
|---|---|---|
| committer | Mathieu Duckerts-Antoine <dam@odoo.com> | 2019-04-01 14:40:51 +0000 |
| commit | a9bcb12f6419aaf087bad4c8a3f7ed7c00dbf3c5 (patch) | |
| tree | 5f25467a6a22ab75db6077eb35bf1b7ff83408a6 /addons/web/static/src/js | |
| parent | d23419fd86d5f8266c37526176c1bf79a09a1684 (diff) | |
[IMP] web: graph view improvements
This commit extends the support of comparison mode for the graph view
and refine the tooltips and rendering in line mode.
The graph view code has also been slightly refactored.
Task ID: 1946138
Diffstat (limited to 'addons/web/static/src/js')
8 files changed, 438 insertions, 334 deletions
diff --git a/addons/web/static/src/js/core/data_comparison_utils.js b/addons/web/static/src/js/core/data_comparison_utils.js index 0e62f2d3ffa..60f88e67d8d 100644 --- a/addons/web/static/src/js/core/data_comparison_utils.js +++ b/addons/web/static/src/js/core/data_comparison_utils.js @@ -2,7 +2,87 @@ odoo.define('web.dataComparisonUtils', function (require) { "use strict"; var fieldUtils = require('web.field_utils'); +var Class = require('web.Class'); +var DateClasses = Class.extend({ + init: function (dateSets, interval) { + // dateSets are assumed pairwise disjoint and ordered naturally. + // at least one of them is non empty. + + // we complete the first inhabited dateSet. Its elements will be + // the default representatives for the classes. + this._maximalLength = 1; + this._referenceIndex = null; + for (var i = 0; i < dateSets.length; i++) { + var dateSet = dateSets[i]; + if (dateSet.length && this._referenceIndex === null) { + this._referenceIndex = i; + } + if (dateSet.length > this._maximalLength) { + this._maximalLength = dateSet.length; + } + } + this._referenceSet = this._contructReferenceSet(dateSets[this._referenceIndex], interval); + this._dateClasses = this._constructDateClasses(dateSets); + }, + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + representative: function (date, index) { + index = index || this._referenceIndex; + return this._dateClass(date)[index]; + }, + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @param {moment[]} dateSet, ordered dateSet + * @param {number} num, indicates number of dates to add + * @param {string} interval, determines time interval between two added dates + * @returns {function} + */ + _contructReferenceSet: function (dateSet, interval) { + var additionalDates = []; + var dateSetLength = dateSet.length; + var diff = this._maximalLength - dateSetLength; + var lastDate = dateSet[dateSetLength - 1]; + for (var i = 0; i < diff; i++) { + var date = moment(lastDate).add(i + 1, interval).format('DD MMM YYYY'); + additionalDates.push(date); + } + return dateSet.concat(additionalDates); + }, + _constructDateClasses: function (dateSets) { + var dateClasses = []; + for (var index = 0; index < this._maximalLength; index++) { + var dateClass = []; + for (var j = 0; j < dateSets.length; j++) { + var dateSet = j=== this._referenceIndex ? this._referenceSet : dateSets[j]; + if (index < dateSet.length) { + dateClass.push(dateSet[index]); + } else { + dateClass.push(undefined); + } + } + dateClasses.push(dateClass); + } + return dateClasses; + }, + _dateClass: function (date) { + var dateClass; + for (var i = 0; i < this._dateClasses.length; i++) { + dateClass = this._dateClasses[i]; + if (_.contains(dateClass, date)) { + break; + } + } + return dateClass; + }, +}); /** * @param {Number} value * @param {Number} comparisonValue @@ -32,11 +112,10 @@ function computeVariation (value, comparisonValue) { signClass = ' o_null'; } return {magnitude: magnitude, signClass: signClass}; - } else { - return {magnitude: NaN}; - } + } else { + return {magnitude: NaN}; + } } - /** * @param {Object} variation * @param {Object} field @@ -44,9 +123,9 @@ function computeVariation (value, comparisonValue) { * @returns {Object} */ function renderVariation (variation, field, options) { - var $variation; + var $variation; if (!isNaN(variation.magnitude)) { - $variation = $('<div>', {class: 'o_variation' + variation.signClass}).html( + $variation = $('<div>', {class: 'o_variation' + variation.signClass}).html( fieldUtils.format.percentage(variation.magnitude, field, options )); } else { @@ -54,7 +133,6 @@ function renderVariation (variation, field, options) { } return $variation; } - /** * @param {JQuery} $node * @param {Number} value @@ -67,18 +145,19 @@ function renderVariation (variation, field, options) { */ function renderComparison ($node, value, comparisonValue, variation, formatter, field, options) { var $variation = renderVariation(variation, field, options); - $node.append($variation); - if (!isNaN(variation.magnitude)) { - $node.append( - $('<div>', {class: 'o_comparison'}) - .html(formatter(value, field, options) + ' <span>vs</span> ' + formatter(comparisonValue, field, options)) - ); - } + $node.append($variation); + if (!isNaN(variation.magnitude)) { + $node.append( + $('<div>', {class: 'o_comparison'}) + .html(formatter(value, field, options) + ' <span>vs</span> ' + formatter(comparisonValue, field, options)) + ); + } } return { - computeVariation: computeVariation, - renderComparison: renderComparison + computeVariation: computeVariation, + DateClasses: DateClasses, + renderComparison: renderComparison }; }); diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js index ba3c198bd21..276b29b71eb 100644 --- a/addons/web/static/src/js/views/abstract_view.js +++ b/addons/web/static/src/js/views/abstract_view.js @@ -330,6 +330,7 @@ var AbstractView = Factory.extend({ var timeRangeDescription = timeRangeMenuData.timeRangeDescription || ''; this.loadParams = _.extend(this.loadParams, { compare: comparisonTimeRange.length > 0, + comparisonField: timeRangeMenuData.comparisonField, comparisonTimeRange: comparisonTimeRange, comparisonTimeRangeDescription: comparisonTimeRangeDescription, context: searchQuery.context, diff --git a/addons/web/static/src/js/views/control_panel/control_panel_model.js b/addons/web/static/src/js/views/control_panel/control_panel_model.js index ad0d80de7a3..f2e28858ba3 100644 --- a/addons/web/static/src/js/views/control_panel/control_panel_model.js +++ b/addons/web/static/src/js/views/control_panel/control_panel_model.js @@ -893,6 +893,7 @@ var ControlPanelModel = mvc.Model.extend({ } context = { timeRangeMenuData: { + comparisonField: filter.fieldName, timeRange: timeRange, timeRangeDescription: timeRangeDescription, comparisonTimeRange: comparisonTimeRange, diff --git a/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js b/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js index 56d43ee6708..dc7a5dc3cc8 100644 --- a/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js +++ b/addons/web/static/src/js/views/control_panel/search/groupby_menu_mixin.js @@ -42,7 +42,7 @@ var GroupByMenuMixin = { return groupableFields[fieldName].string; }); this.groupableFields = groupableFields; - var groupBys = this._getGroupBys(this.model.get().groupedBy); + var groupBys = this._getGroupBys(this.model.get().groupBy); this.groupByMenu = new GroupByMenu(this, groupBys, this.groupableFields); this.groupByMenu.insertAfter($node.find('div:first')); }, @@ -110,7 +110,7 @@ var GroupByMenuMixin = { _onItemOptionClicked: function (ev) { var fieldName = ev.data.id; var optionId = ev.data.optionId; - var activeGroupBys = this.model.get().groupedBy; + var activeGroupBys = this.model.get().groupBy; var currentOptionId = activeGroupBys.reduce( function (optionId, groupby) { if (groupby.split(':')[0] === fieldName){ @@ -140,7 +140,7 @@ var GroupByMenuMixin = { */ _onMenuItemClicked: function (ev) { var fieldName = ev.data.id; - var activeGroupBys = this.model.get().groupedBy; + var activeGroupBys = this.model.get().groupBy; var groupByFieldNames = _.map(activeGroupBys, function (groupby) { return groupby.split(':')[0]; }); diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js index 15f7fbeaa35..a88a94b786f 100644 --- a/addons/web/static/src/js/views/graph/graph_controller.js +++ b/addons/web/static/src/js/views/graph/graph_controller.js @@ -71,13 +71,7 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ context: { graph_measure: state.measure, graph_mode: state.mode, - graph_groupbys: state.groupedBy, - // this parameter is not used anywher for now - // the idea would be to seperate intervals from - // fieldnames in groupbys. This could be done - // in graph view only or everywhere but this is - // a big refactoring. - graph_intervalMapping: state.intervalMapping, + graph_groupbys: state.groupBy, } }; }, @@ -115,10 +109,10 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ * override * * @private - * @param {string[]} groupBys + * @param {string[]} groupBy */ - _setGroupby: function (groupBys) { - this.update({groupBy: groupBys}); + _setGroupby: function (groupBy) { + this.update({groupBy: groupBy}); }, /** @@ -127,7 +121,7 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ * * private * - * @param {string} mode one of 'pie', 'line' or 'bar' + * @param {'pie'|'line'|'bar'} mode */ _setMode: function (mode) { this.update({mode: mode}); diff --git a/addons/web/static/src/js/views/graph/graph_model.js b/addons/web/static/src/js/views/graph/graph_model.js index 91b017524d8..33b8896b29d 100644 --- a/addons/web/static/src/js/views/graph/graph_model.js +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -1,11 +1,13 @@ odoo.define('web.GraphModel', function (require) { "use strict"; +var core = require('web.core'); +var _t = core._t; + /** * The graph model is responsible for fetching and processing data from the - * server. It basically just do a read_group and format/normalize data. + * server. It basically just do a(some) read_group(s) and format/normalize data. */ - var AbstractModel = require('web.AbstractModel'); return AbstractModel.extend({ @@ -23,18 +25,12 @@ return AbstractModel.extend({ //-------------------------------------------------------------------------- /** - * We defend against outside modifications by extending the chart data. It - * may be overkill. - * - * @todo Adding the fields parameter looks wrong. If the renderer or the - * controller need the fields, they should get it via their init method. + * override * * @returns {Object} */ get: function () { - return _.extend({}, this.chart, { - fields: this.fields - }); + return this.chart; }, /** * Initial loading. @@ -43,13 +39,19 @@ return AbstractModel.extend({ * should be done by the graphView I think. * * @param {Object} params - * @param {string} params.mode one of 'pie', 'bar', 'line - * @param {string} params.measure a valid field name - * @param {string[]} params.groupBys a list of valid field names + * @param {boolean} params.compare * @param {Object} params.context + * @param {Object} params.fields + * @param {string[]} params.comparisonTimeRange * @param {string[]} params.domain - * @param {Object} params.intervalMapping object linking fieldNames with intervals. - * this could be useful to simplify the code. For now this parameter is not used. + * @param {string[]} params.groupBy a list of valid field names + * @param {string[]} params.timeRange + * @param {string} params.comparisonField + * @param {string} params.comparisonTimeRangeDescription + * @param {string} params.measure a valid field name + * @param {'pie'|'bar'|'line'} params.mode + * @param {string} params.modelName + * @param {string} params.timeRangeDescription * @returns {Promise} The promise does not return a handle, we don't need * to keep track of various entities. */ @@ -59,25 +61,21 @@ return AbstractModel.extend({ this.fields = params.fields; this.modelName = params.modelName; this.chart = { - compare: params.compare, + comparisonField: params.comparisonField, comparisonTimeRange: params.comparisonTimeRange, comparisonTimeRangeDescription: params.comparisonTimeRangeDescription, - data: [], - groupedBy: params.groupedBy.length ? params.groupedBy : groupBys, - // this parameter is not used anywhere for now. - // the idea would be to seperate intervals from - // fieldnames in groupbys. This could be done - // in graph view only or everywhere but this is - // a big refactoring. - intervalMapping: params.intervalMapping, + compare: params.compare, + context: params.context, + dataPoints: [], + domain: params.domain, + groupBy: params.groupedBy.length ? params.groupedBy : groupBys, measure: params.context.graph_measure || params.measure, mode: params.context.graph_mode || params.mode, + origins: [], timeRange: params.timeRange, timeRangeDescription: params.timeRangeDescription, - domain: params.domain, - context: params.context, }; - return this._loadGraph(); + return this._loadGraph(this._getDomains()); }, /** * Reload data. It is similar to the load function. Note that we ignore the @@ -88,31 +86,33 @@ return AbstractModel.extend({ * * @param {any} handle ignored! * @param {Object} params + * @param {Object} [params.context] * @param {string[]} [params.domain] * @param {string[]} [params.groupBy] - * @param {string} [params.mode] one of 'bar', 'pie', 'line' * @param {string} [params.measure] a valid field name + * @param {string} [params.mode] one of 'bar', 'pie', 'line' * @returns {Promise} */ reload: function (handle, params) { if ('context' in params) { this.chart.context = params.context; - this.chart.groupedBy = params.context.graph_groupbys || this.chart.groupedBy; + this.chart.groupBy = params.context.graph_groupbys || this.chart.groupBy; this.chart.measure = params.context.graph_measure || this.chart.measure; this.chart.mode = params.context.graph_mode || this.chart.mode; var timeRangeMenuData = params.context.timeRangeMenuData; if (timeRangeMenuData) { + this.chart.comparisonField = timeRangeMenuData.comparisonField || undefined; this.chart.comparisonTimeRange = timeRangeMenuData.comparisonTimeRange || []; this.chart.compare = this.chart.comparisonTimeRange.length > 0; - this.chart.timeRange = timeRangeMenuData.timeRange || []; this.chart.comparisonTimeRangeDescription = timeRangeMenuData.comparisonTimeRangeDescription; + this.chart.timeRange = timeRangeMenuData.timeRange || []; this.chart.timeRangeDescription = timeRangeMenuData.timeRangeDescription; } else { - this.chart = _.omit(this.chart, 'comparisonData'); + this.chart.comparisonField = undefined; this.chart.comparisonTimeRange = []; this.chart.compare = false; - this.chart.timeRange = []; this.chart.comparisonTimeRangeDescription = undefined; + this.chart.timeRange = []; this.chart.timeRangeDescription = undefined; } } @@ -120,10 +120,7 @@ return AbstractModel.extend({ this.chart.domain = params.domain; } if ('groupBy' in params) { - this.chart.groupedBy = params.groupBy.length ? params.groupBy : this.initialGroupBys; - } - if ('intervalMapping' in params) { - this.chart.intervalMapping = params.intervalMapping; + this.chart.groupBy = params.groupBy.length ? params.groupBy : this.initialGroupBys; } if ('measure' in params) { this.chart.measure = params.measure; @@ -132,7 +129,7 @@ return AbstractModel.extend({ this.chart.mode = params.mode; return Promise.resolve(); } - return this._loadGraph(); + return this._loadGraph(this._getDomains()); }, //-------------------------------------------------------------------------- @@ -140,16 +137,32 @@ return AbstractModel.extend({ //-------------------------------------------------------------------------- /** - * Fetch and process graph data. It is basically a read_group with correct - * fields. We have to do some light processing to separate date groups - * in the field list, because they can be defined with an aggregation - * function, such as my_date:week + * @returns {Object[]} + */ + _getDomains: function () { + var domains = [this.chart.domain.concat(this.chart.timeRange)]; + this.chart.origins = [this.chart.timeRangeDescription]; + if (this.chart.compare) { + domains.push(this.chart.domain.concat(this.chart.comparisonTimeRange)); + this.chart.origins.push(this.chart.comparisonTimeRangeDescription); + } + return domains; + }, + + /** + * Fetch and process graph data. It is basically a(some) read_group(s) + * with correct fields for each domain. We have to do some light processing + * to separate date groups in the field list, because they can be defined + * with an aggregation function, such as my_date:week. * + * @param {Array[]} domains * @returns {Promise} */ - _loadGraph: function () { - var groupedBy = this.chart.groupedBy; - var fields = _.map(groupedBy, function (groupBy) { + _loadGraph: function (domains) { + var self = this; + this.chart.dataPoints = []; + var groupBy = this.chart.groupBy; + var fields = _.map(groupBy, function (groupBy) { return groupBy.split(':')[0]; }); @@ -164,55 +177,46 @@ return AbstractModel.extend({ var context = _.extend({fill_temporal: true}, this.chart.context); var defs = []; - defs.push(this._rpc({ - model: this.modelName, - method: 'read_group', - context: context, - domain: this.chart.domain.concat(this.chart.timeRange), - fields: fields, - groupBy: groupedBy, - lazy: false, - }).then(this._processData.bind(this, 'data'))); - if (this.chart.compare) { - defs.push(this._rpc({ - model: this.modelName, + domains.forEach(function (domain, originIndex) { + defs.push(self._rpc({ + model: self.modelName, method: 'read_group', context: context, - domain: this.chart.domain.concat(this.chart.comparisonTimeRange), + domain: domain, fields: fields, - groupBy: groupedBy, + groupBy: groupBy, lazy: false, - }).then(this._processData.bind(this, 'comparisonData'))); - } - + }).then(self._processData.bind(self, originIndex))); + }); return Promise.all(defs); }, /** * Since read_group is insane and returns its result on different keys * depending of some input, we have to normalize the result. - * The final chart data is added to this.chart object. + * Each group comming from the read_group produces a dataPoint * * @todo This is not good for race conditions. The processing should get * the object this.chart in argument, or an array or something. We want to * avoid writing on a this.chart object modified by a subsequent read_group * - * @param {String} dataKey - * @param {any} raw_data result from the read_group + * @param {number} originIndex + * @param {any} rawData result from the read_group */ - _processData: function (dataKey, raw_data) { + _processData: function (originIndex, rawData) { var self = this; - var is_count = this.chart.measure === '__count__'; - var data_pt, labels; + var isCount = this.chart.measure === '__count__'; + var labels; - this.chart[dataKey] = []; - for (var i = 0; i < raw_data.length; i++) { - data_pt = raw_data[i]; - labels = _.map(this.chart.groupedBy, function (field) { - return self._sanitizeValue(data_pt[field], field); + function getLabels (dataPt) { + return self.chart.groupBy.map(function (field) { + return self._sanitizeValue(dataPt[field], field.split(":")[0]); }); - var count = data_pt.__count || data_pt[this.chart.groupedBy[0]+'_count'] || 0; - var value = is_count ? count : data_pt[this.chart.measure]; + } + rawData.forEach(function (dataPt){ + labels = getLabels(dataPt); + var count = dataPt.__count || dataPt[self.chart.groupBy[0]+'_count'] || 0; + var value = isCount ? count : dataPt[self.chart.measure]; if (value instanceof Array) { // when a many2one field is used as a measure AND as a grouped // field, bad things happen. The server will only return the @@ -223,28 +227,30 @@ return AbstractModel.extend({ // value for that field. value = 1; } - this.chart[dataKey].push({ + self.chart.dataPoints.push({ count: count, value: value, labels: labels, + originIndex: originIndex, }); - } + }); }, /** * Helper function (for _processData), turns various values in a usable * string form, that we can display in the interface. * - * @param {any} value some value received by the read_group rpc - * @param {string} field the name of the corresponding field + * @param {any} value value for the field fieldName received by the read_group rpc + * @param {string} fieldName * @returns {string} */ - _sanitizeValue: function (value, field) { - var fieldName = field.split(':')[0]; + _sanitizeValue: function (value, fieldName) { if (value === false && this.fields[fieldName].type !== 'boolean') { - return undefined; + return _t("Undefined"); + } + if (value instanceof Array) { + return value[1]; } - if (value instanceof Array) return value[1]; - if (field && (this.fields[fieldName].type === 'selection')) { + if (fieldName && (this.fields[fieldName].type === 'selection')) { var selected = _.where(this.fields[fieldName].selection, {0: value})[0]; return selected ? selected[1] : value; } diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js index 6311df5d8b2..34d0ed4eb80 100644 --- a/addons/web/static/src/js/views/graph/graph_renderer.js +++ b/addons/web/static/src/js/views/graph/graph_renderer.js @@ -13,9 +13,11 @@ var AbstractRenderer = require('web.AbstractRenderer'); var config = require('web.config'); var core = require('web.core'); var field_utils = require('web.field_utils'); +var dataComparisonUtils = require('web.dataComparisonUtils'); var _t = core._t; var qweb = core.qweb; +var DateClasses = dataComparisonUtils.DateClasses; var CHART_TYPES = ['pie', 'bar', 'line']; @@ -34,10 +36,24 @@ return AbstractRenderer.extend({ */ init: function (parent, state, params) { this._super.apply(this, arguments); - this.isComparison = !!state.comparisonData; + this.datasets = []; this.isEmbedded = params.isEmbedded; - this.stacked = this.isComparison ? false : params.stacked; + this.stacked = this.state.compare ? false : params.stacked; this.title = params.title || ''; + this.fields = params.fields || {}; + this.formatOptions = { + // allow to decide if utils.human_number should be used + humanReadable: function (value) { + return Math.abs(value) >= 1000; + }, + // with the choices below, 1236 is represented by 1.24k + minDigits: 1, + decimals: 2, + // avoid comma separators for thousands in numbers when human_number is used + formatterCallback: function (str) { + return str; + } + }; }, /** * @override @@ -80,8 +96,7 @@ return AbstractRenderer.extend({ * @param {Object} params */ updateState: function (state, params) { - this.isComparison = !!state.comparisonData; - this.stacked = this.isComparison ? false : params.stacked; + this.stacked = this.state.compare ? false : params.stacked; return this._super.apply(this, arguments); }, @@ -90,6 +105,16 @@ return AbstractRenderer.extend({ //-------------------------------------------------------------------------- /** + * Used to format correctly the value in tooltips and y axis + * + * @private + * @param {number} value + * @returns {strin} The number formatted + */ + _formatValue: function (value) { + return field_utils.format.float(value, this.formatOption); + }, + /** * Render the chart. * * Note that This method is synchronous, but the actual rendering is done @@ -111,7 +136,7 @@ return AbstractRenderer.extend({ title: _t('Invalid mode for chart'), message: _t('Cannot render chart with mode : ') + this.state.mode }); - } else if (!this.state.data.length && this.state.mode !== 'pie') { + } else if (!this.state.dataPoints.length && this.state.mode !== 'pie') { this.$el.empty(); this.$el.append(qweb.render('GraphView.error', { title: _t("No data to display"), @@ -127,6 +152,126 @@ return AbstractRenderer.extend({ } return this._super.apply(this, arguments); }, + + _prepareDataSets: function (dataPoints) { + var self = this; + var measure = this.fields[this.state.measure].string; + + function getGroupLabel(dataPt) { + // ([origin] + second to last groupBys) or measure + var z = dataPt.labels.slice(1).join("/"); + if (self.state.compare) { + z = self.state.origins[dataPt.originIndex] + (z ? ('/' + z) : ''); + } + z = z || measure; + return z; + } + + function getXLabel(dataPt) { + return dataPt.labels.slice(0, 1).join("/") || _t('Total'); + } + + var comparisonField = this.state.comparisonField; + var groupBys = this.state.groupBy.map(function (gb) { + return gb.split(":")[0]; + }); + + // if dateIdentification is true, we will identify dates coming from + // different origins if they have same rank in the corresponding date sets + // (date sets are assumed pairwise disjoint, naturally ordered and not all empty) + var dateIdentification = groupBys.indexOf(comparisonField) === 0; + + var dateSets; + var dateClasses; + if (dateIdentification) { + dateSets = this.state.origins.map(function () { + return []; + }); + dataPoints.forEach(function (dataPt) { + dateSets[dataPt.originIndex].push(dataPt.labels[0]); + }); + dateSets = dateSets.map(function (dateSet) { + return _.uniq(dateSet); + }); + var interval = this.state.groupBy[0].split(":")[1] || 'month'; + dateClasses = new DateClasses(dateSets, interval); + } + + // dataPoints --> points separated into different groups + var groups = {}; + var groupInfo = {}; + var xRange = []; + dataPoints.forEach(function (dataPt) { + var groupLabel = getGroupLabel(dataPt); + if (!(groupLabel in groups)) { + groups[groupLabel] = {}; + } + if (!(groupLabel in groupInfo)) { + groupInfo[groupLabel] = { + // by construction, all data points in the same group + // have the same origin. + originIndex: dataPt.originIndex, + xLabels: {} + }; + } + var xLabel = getXLabel(dataPt); + var xValue; + if (dateIdentification) { + xValue = dateClasses.representative(xLabel); + } else { + xValue = xLabel; + } + xRange.push(xValue); + + groups[groupLabel][xValue] = dataPt.value; + groupInfo[groupLabel].xLabels[xValue] = xLabel; + }); + + xRange = _.uniq(xRange); + + // If not dateIdentification, each group is completed to have points over each x in xrange + // If dateIdentification, we complete each group to have points over each x corresponding + // to a date coming from a datapoint from the same origin. + // Each group is transformed into a single datum for nvd3. + var datasets = Object.keys(groups).map(function (groupLabel) { + var range; + var originIndex = groupInfo[groupLabel].originIndex; + if (dateIdentification) { + range = xRange.slice(0, dateSets[originIndex].length); + } else { + range = xRange; + } + var points = range.reduce( + function (acc, xValue) { + var y = groups[groupLabel][xValue]; + if (!y) { + if (dateIdentification) { + groupInfo[groupLabel].xLabels[xValue] = dateClasses.representative(xValue, originIndex); + } else { + groupInfo[groupLabel].xLabels[xValue] = xValue; + } + } + acc.push({ + x: xValue, + y: y || 0, + }); + return acc; + }, + [] + ); + return { + key: groupLabel, + values: points + }; + }); + + return { + datasets: datasets, + ticksLabels: xRange, + groupInfo: groupInfo, + }; + }, + /** * Helper function to set up data properly for the multiBarChart model in * nvd3. @@ -134,88 +279,32 @@ return AbstractRenderer.extend({ * @returns {nvd3 chart} */ _renderBarChart: function () { - // prepare data for bar chart var self = this; - var data = []; - var values; - var measure = this.state.fields[this.state.measure].string; - - // undefined label value becomes a string 'Undefined' translated - this.state.data.forEach(self._sanitizeLabel); - - if (this.state.groupedBy.length === 0) { - data = [{ - values: [{ - x: _t('Total'), - y: this.state.data[0].value}], - key: this.state.timeRangeDescription ? - this.state.timeRangeDescription: - measure, - }]; - } else if (this.state.groupedBy.length === 1) { - values = this.state.data.map(function (datapt, index) { - return {x: datapt.labels, y: datapt.value}; - }); - data.push({ - values: values, - key: this.state.timeRangeDescription ? - this.state.timeRangeDescription: - measure, - }); - if (this.state.comparisonData) { - values = this.state.comparisonData.map(function (datapt, index) { - return {x: datapt.labels, y: datapt.value}; - }); - data.push({ - values: values, - key: this.state.comparisonTimeRangeDescription ? - this.state.comparisonTimeRangeDescription: - measure, - color: '#ff7f0e', - }); - } - } else if (this.state.groupedBy.length > 1) { - var xlabels = [], - series = [], - label, serie, value; - values = {}; - for (var i = 0; i < this.state.data.length; i++) { - label = this.state.data[i].labels[0]; - serie = this.state.data[i].labels.slice(1).join("/"); - value = this.state.data[i].value; - if ((!xlabels.length) || (xlabels[xlabels.length-1] !== label)) { - xlabels.push(label); - } - series.push(serie); - if (!(serie in values)) {values[serie] = {};} - values[serie][label] = this.state.data[i].value; - } - series = _.uniq(series); - data = []; - var current_serie, j; - for (i = 0; i < series.length; i++) { - current_serie = {values: [], key: series[i]}; - for (j = 0; j < xlabels.length; j++) { - current_serie.values.push({ - x: xlabels[j], - y: values[series[i]][xlabels[j]] || 0, - }); - } - data.push(current_serie); - } - } - // For one level Bar chart View, we keep only groups where count > 0 - if (this.state.groupedBy.length <= 1) { - data[0].values = _.filter(data[0].values, function (elem, index) { - return self.state.data[index].count > 0; + // prepare data for bar chart + var dataPoints = this.state.dataPoints.filter(function (dataPt) { + return dataPt.count > 0; + }); + + // put data in a format for nvd3 + var dataProcessed = this._prepareDataSets(dataPoints); + var groupInfo = dataProcessed.groupInfo; + this.datasets = dataProcessed.datasets; + + // style data + if (this.state.groupBy.length === 1) { + this.datasets.forEach(function (group) { + if (groupInfo[group.key].originIndex === 1) { + group.color = '#ff7f0e'; + } }); } + // nvd3 specific var $svgContainer = $('<div/>', {class: 'o_graph_svg_container'}); this.$el.append($svgContainer); var svg = d3.select($svgContainer[0]).append('svg'); - svg.datum(data); + svg.datum(this.datasets); svg.transition().duration(0); @@ -228,37 +317,39 @@ return AbstractRenderer.extend({ 'grouped': _t('Grouped'), 'stacked': _t('Stacked'), }, - showLegend: _.size(data) <= MAX_LEGEND_LENGTH, + showLegend: _.size(this.datasets) <= MAX_LEGEND_LENGTH, showXAxis: true, showYAxis: true, rightAlignYAxis: false, stacked: this.stacked, reduceXTicks: false, rotateLabels: -20, - showControls: (this.state.groupedBy.length > 1) + showControls: (this.state.groupBy.length > 1) }); - chart.yAxis.tickFormat(function (d) { - var measure_field = self.state.fields[self.measure]; - return field_utils.format.float(d, { - digits: measure_field && measure_field.digits || [69, 2], + + chart.yAxis + .tickFormat(function (d) { + return self._formatValue(d); }); - }); + chart.tooltip.contentGenerator(function (data) { var lines = data.series.map(function (serie) { - var label = data.value; - if (self.state.groupedBy.length > 1 || self.isComparison) { + var label = groupInfo[serie.key].xLabels[data.value]; + if (self.state.groupBy.length > 1 || self.state.compare) { label = label + "/" + serie.key; } return { color: serie.color, label: label, - value: serie.value, + value: self._formatValue(serie.value), }; }); return qweb.render("web.Chart.Tooltip", { - title: self.state.fields[self.state.measure].string, - lines: lines, + title: self.fields[self.state.measure].string, + lines: lines.sort(function (line1, line2) { + return line2.value - line1.value; + }), }); }); @@ -274,17 +365,16 @@ return AbstractRenderer.extend({ *. * @returns {nvd3 chart|undefined} */ - _renderPieChart: function (stateData) { + _renderPieChart: function (originIndex) { var self = this; - var data = []; var all_negative = true; var some_negative = false; var all_zero = true; - // undefined label value becomes a string 'Undefined' translated - stateData.forEach(self._sanitizeLabel); - - stateData.forEach(function (datapt) { + var dataPoints = this.state.dataPoints.filter(function (dataPt) { + return dataPt.originIndex === originIndex && dataPt.count > 0; + }); + dataPoints.forEach(function (datapt) { all_negative = all_negative && (datapt.value < 0); some_negative = some_negative || (datapt.value < 0); all_zero = all_zero && (datapt.value === 0); @@ -298,9 +388,9 @@ return AbstractRenderer.extend({ return; } if (all_zero) { - if (this.isEmbedded || this.isComparison) { + if (this.isEmbedded || this.state.compare) { // add fake data to display an empty pie chart - data = [{ + this.datasets = [{ x : "No data" , y : 1 }]; @@ -313,22 +403,18 @@ return AbstractRenderer.extend({ return; } } else { - if (this.state.groupedBy.length) { - data = stateData.map(function (datapt) { - return {x:datapt.labels.join("/"), y: datapt.value}; - }); - } - - // We only keep groups where count > 0 - data = _.filter(data, function (elem, index) { - return stateData[index].count > 0; + this.datasets = dataPoints.map(function (datapt) { + return { + x: datapt.labels.join("/") || _t('Total'), + y: datapt.value, + }; }); } var $svgContainer = $('<div/>', {class: 'o_graph_svg_container'}); this.$el.append($svgContainer); var svg = d3.select($svgContainer[0]).append('svg'); - svg.datum(data); + svg.datum(this.datasets); svg.transition().duration(100); @@ -348,7 +434,7 @@ return AbstractRenderer.extend({ var chart = nv.models.pieChart().labelType('percent'); chart.options({ delay: 250, - showLegend: !all_zero && (legend_right || _.size(data) <= MAX_LEGEND_LENGTH), + showLegend: !all_zero && (legend_right || _.size(this.datasets) <= MAX_LEGEND_LENGTH), legendPosition: legend_right ? 'right' : 'top', transition: 100, color: color, @@ -357,11 +443,11 @@ return AbstractRenderer.extend({ chart.tooltip.contentGenerator(function (data) { return qweb.render("web.Chart.Tooltip", { - title: self.state.fields[self.state.measure].string, + title: self.fields[self.state.measure].string, lines : [{ color: data.color, label: data.data.x, - value: data.data.y, + value: self._formatValue(data.data.y), }], }); }); @@ -378,93 +464,44 @@ return AbstractRenderer.extend({ _renderLineChart: function () { var self = this; - // Remove Undefined of first GroupBy - var graphData = _.filter(this.state.data, function (elem) { - return elem.labels[0] !== undefined && elem.labels[0] !== _t("Undefined"); + // remove some data points + var dataPoints = this.state.dataPoints.filter(function (dataPt) { + return dataPt.labels[0] !== _t("Undefined"); }); - // undefined label value becomes a string 'Undefined' translated - this.state.data.forEach(self._sanitizeLabel); - - var data = []; - var ticksLabels = []; - var measure = this.state.fields[this.state.measure].string; - var values; + var dataProcessed = this._prepareDataSets(dataPoints); + var ticksLabels = dataProcessed.ticksLabels; + var groupInfo = dataProcessed.groupInfo; + this.datasets = dataProcessed.datasets; - if (this.state.groupedBy.length === 1) { - values = graphData.map(function (datapt, index) { - return {x: index, y: datapt.value}; - }); - data.push({ - area: true, - values: values, - key: this.state.timeRangeDescription ? - this.state.timeRangeDescription: - measure, - }); - if (this.state.comparisonData && this.state.comparisonData.length > 0) { - values = this.state.comparisonData.map(function (datapt, index) { - return {x: index, y: datapt.value}; - }); - data.push({ - values: values, - key: this.state.comparisonTimeRangeDescription ? - this.state.comparisonTimeRangeDescription: - measure, - color: '#ff7f0e', - }); - } - - for (var i = 0; i < graphData.length; i++) { - ticksLabels.push(graphData[i].labels); - } - if (this.state.comparisonData && this.state.comparisonData.length > this.state.data.length) { - var diff = this.state.comparisonData.length - this.state.data.length; - var length = self.state.data.length; - var diffTime = 0; - if (length < self.state.data.length) { - var date1 = moment(self.state.data[length - 1].labels[0]); - var date2 = moment(self.state.data[length - 2].labels[0]); - diffTime = date1 - date2; - for (i = 0; i < diff; i++) { - var value = moment(date1).add(diffTime + i * diffTime).format('DD MMM YYYY'); - ticksLabels.push([value]); - } - } - } - } else if (this.state.groupedBy.length > 1) { - var data_dict = {}; - var tick = 0; - var serie, tickLabel; - var identity = function (p) {return p;}; - for (var i = 0; i < graphData.length; i++) { - if (graphData[i].labels[0] !== tickLabel) { - tickLabel = graphData[i].labels[0]; - ticksLabels.push(tickLabel); - tick++; + // style data + if (this.state.groupBy.length === 1) { + this.datasets.forEach(function (group) { + if (groupInfo[group.key].originIndex === 0) { + group.area = true; } - serie = graphData[i].labels.slice(1).join("/"); - if (!data_dict[serie]) { - data_dict[serie] = { - values: [], - key: serie, - }; + if (groupInfo[group.key].originIndex === 1) { + group.color = '#ff7f0e'; } - data_dict[serie].values.push({ - x: tick - 1, y: graphData[i].value, - }); - data = _.map(data_dict, identity); - } + }); } + this.datasets.forEach(function (group) { + group.values = group.values.map(function (value) { + return { + x: ticksLabels.indexOf(value.x), + y: value.y + }; + }); + }); var $svgContainer = $('<div/>', { class: 'o_graph_svg_container'}); // Split the tooltip into columns for large data because some portion goes out off the screen. - if (data.length >= SPLIT_THRESHOLD) { + if (this.datasets.length >= SPLIT_THRESHOLD) { $svgContainer.addClass('o_tooltip_split_in_columns'); } this.$el.append($svgContainer); var svg = d3.select($svgContainer[0]).append('svg'); - svg.datum(data); + svg.datum(this.datasets); svg.transition().duration(0); @@ -472,9 +509,10 @@ return AbstractRenderer.extend({ chart.options({ margin: {left: 0, bottom: 20, top: 0, right: 0}, useInteractiveGuideline: true, - showLegend: _.size(data) <= MAX_LEGEND_LENGTH, + showLegend: _.size(this.datasets) <= MAX_LEGEND_LENGTH, showXAxis: true, showYAxis: true, + stacked: true, }); chart.forceY([0]); chart.xAxis @@ -484,35 +522,37 @@ return AbstractRenderer.extend({ chart.yAxis .showMaxMin(false) .tickFormat(function (d) { - return field_utils.format.float(d, { - digits : self.state.fields[self.state.measure] && self.state.fields[self.state.measure].digits || [69, 2], - }); + return self._formatValue(d); }); chart.yAxis.tickPadding(5); chart.yAxis.orient("right"); chart.interactiveLayer.tooltip.contentGenerator(function (data) { - var lines = data.series.map(function (serie) { - var label = ticksLabels[data.value]; - if (self.state.groupedBy.length > 1 || self.isComparison) { + var lines = data.series.filter(function (serie) { + return serie.data.x === data.value; + }).map(function (serie) { + var label = groupInfo[serie.key].xLabels[ticksLabels[data.value]]; + if (self.state.groupBy.length > 1 || self.state.compare) { label = label + "/" + serie.key; } return { color: serie.color, label: label, - value: serie.value, + value: self._formatValue(serie.value), }; }); return qweb.render("web.Chart.Tooltip", { - title: self.state.fields[self.state.measure].string, - lines: lines, + title: self.fields[self.state.measure].string, + lines: lines.sort(function (line1, line2) { + return line2.value - line1.value; + }), }); }); chart(svg); // Bigger line (stroke-width 1.5 is hardcoded in nv.d3) - $svgContainer.find('.nvd3 .nv-groups g.nv-group').css('stroke-width', '2px') + $svgContainer.find('.nvd3 .nv-groups g.nv-group').css('stroke-width', '2px'); // Delete first and last label because there is no enough space because // of the tiny margins. @@ -533,14 +573,14 @@ return AbstractRenderer.extend({ this.$el.empty(); - var chartResize = function (chart){ + function chartResize (chart){ if (chart && chart.tooltip.chartContainer) { self.to_remove = chart.update; nv.utils.onWindowResize(chart.update); chart.tooltip.chartContainer(self.$('.o_graph_svg_container').last()[0]); } } - var chart = this['_render' + _.str.capitalize(this.state.mode) + 'Chart'](this.state.data); + var chart = this['_render' + _.str.capitalize(this.state.mode) + 'Chart'](0); if (chart) { chart.dispatch.on('renderEnd', function () { @@ -548,12 +588,11 @@ return AbstractRenderer.extend({ $('.nv-y .tick > line').attr('x2', function (i, value) { return Math.abs(value); }); - }) - + }); chartResize(chart); } - if (this.state.mode === 'pie' && this.isComparison) { + if (this.state.mode === 'pie' && this.state.compare) { // Render graph title var chartTitle = this.title + ' (' + this.state.timeRangeDescription + ')'; this.$('.o_graph_svg_container').last().prepend($('<label/>', { @@ -561,7 +600,7 @@ return AbstractRenderer.extend({ })); // Instantiate comparison graph - var comparisonChart = this['_render' + _.str.capitalize(this.state.mode) + 'Chart'](this.state.comparisonData); + var comparisonChart = this['_render' + _.str.capitalize(this.state.mode) + 'Chart'](1); // Render comparison graph title var comparisonChartTitle = this.title + ' (' + this.state.comparisonTimeRangeDescription + ')'; this.$('.o_graph_svg_container').last().prepend($('<label/>', { @@ -577,20 +616,6 @@ return AbstractRenderer.extend({ })); } }, - /** - * Helper function, turns label value into a usable string form if it is - * undefined, that we can display in the interface. - * - * @param {Array} datapt an array that contains groupby labels of the graph - * received by the read_group rpc. - * @returns {string} - */ - _sanitizeLabel: function (datapt) { - datapt.labels = datapt.labels.map(function(label) { - if (label === undefined) return _t("Undefined"); - return label; - }); - }, }); }); diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js index 4567daf0ead..e731835bc30 100644 --- a/addons/web/static/src/js/views/graph/graph_view.js +++ b/addons/web/static/src/js/views/graph/graph_view.js @@ -49,14 +49,12 @@ var GraphView = AbstractView.extend({ var groupBys = []; var measures = {__count__: {string: _t("Count"), type: "integer"}}; var groupableFields = {}; - var intervalMapping = {}; this.fields.__count__ = {string: _t("Count"), type: "integer"}; this.arch.children.forEach(function (field) { var fieldName = field.attrs.name; var interval = field.attrs.interval; if (interval) { - intervalMapping[fieldName] = interval; fieldName = fieldName + ':' + interval; } if (field.attrs.type === 'measure') { @@ -81,13 +79,13 @@ var GraphView = AbstractView.extend({ this.controllerParams.measures = measures; this.controllerParams.groupableFields = groupableFields; + this.rendererParams.fields = this.fields; this.rendererParams.stacked = this.arch.attrs.stacked !== "False"; this.rendererParams.title = this.arch.attrs.title; // TODO: use attrs.string instead this.loadParams.mode = this.arch.attrs.type || 'bar'; this.loadParams.measure = measure || '__count__'; - this.loadParams.groupBys = groupBys || []; - this.loadParams.intervalMapping = intervalMapping; + this.loadParams.groupBys = groupBys; this.loadParams.fields = this.fields; this.loadParams.comparisonDomain = params.comparisonDomain; }, |
