diff options
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; }, |
