diff options
Diffstat (limited to 'addons/web/static/src/js')
5 files changed, 792 insertions, 545 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 60f88e67d8d..b55cc4b93d8 100644 --- a/addons/web/static/src/js/core/data_comparison_utils.js +++ b/addons/web/static/src/js/core/data_comparison_utils.js @@ -5,82 +5,64 @@ 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; + /** + * This small class offers a light API to manage equivalence classes of + * dates. Two dates in different dateSets are declared equivalent when + * their indexes are equal. + * + * @param {Array[]} dateSets, a list of list of dates + */ + init: function (dateSets) { + // At least one dateSet must be non empty. + // The completion of the first inhabited dateSet will serve as a reference set. + // The reference set elements will be the default representatives for the classes. + this.dateSets = dateSets; + 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; + if (dateSet.length && this.referenceIndex === null) { + this.referenceIndex = i; } } - 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} + * Returns the index of a date in a given datesetIndex. This can be considered + * as the date class itself. + * + * @param {number} datesetIndex + * @param {string} date + * @return {number} */ - _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); + dateClass: function (datesetIndex, date) { + return this.dateSets[datesetIndex].indexOf(date); }, - _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; + /** + * returns the dates occuring in a given class + * + * @param {number} dateClass + * @return {string[]} + */ + dateClassMembers: function (dateClass) { + return _.uniq(_.compact(this.dateSets.map(function (dateSet) { + return dateSet[dateClass]; + }))); }, - _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; + /** + * return the representative of a date class from a date set specified by an + * index. + * + * @param {number} dateClass + * @param {number} [index] + * @return {string} + */ + representative: function (dateClass, index) { + index = index === undefined ? this.referenceIndex : index; + return this.dateSets[index][dateClass]; }, }); /** 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 a88a94b786f..9e2ebfc0a78 100644 --- a/addons/web/static/src/js/views/graph/graph_controller.js +++ b/addons/web/static/src/js/views/graph/graph_controller.js @@ -114,12 +114,11 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ _setGroupby: function (groupBy) { this.update({groupBy: groupBy}); }, - /** * @todo remove this and directly calls update. Update should be overridden * and modified to call _updateButtons * - * private + * @private * * @param {'pie'|'line'|'bar'} mode */ @@ -139,6 +138,15 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ }); }, /** + * @private + * + * @param {boolean} stacked + */ + _toggleStackMode: function (stacked) { + this.update({stacked: stacked}); + this._updateButtons(); + }, + /** * override * * @private @@ -160,6 +168,11 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ this.$buttons .find('.o_graph_button[data-mode="' + state.mode + '"]') .addClass('active'); + this.$buttons + .find('.o_graph_button[data-mode="stack"]') + .data('stacked', state.stacked) + .toggleClass('active', state.stacked) + .toggleClass('o_hidden', state.mode !== 'bar'); _.each(this.$measureList.find('.dropdown-item'), function (item) { var $item = $(item); $item.toggleClass('selected', $item.data('field') === state.measure); @@ -180,7 +193,11 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{ var $target = $(ev.target); var field; if ($target.hasClass('o_graph_button')) { - this._setMode($target.data('mode')); + if (_.contains(['bar','line', 'pie'], $target.data('mode'))) { + this._setMode($target.data('mode')); + } else if ($target.data('mode') === 'stack') { + this._toggleStackMode(!$target.data('stacked')); + } } else if ($target.parents('.o_graph_measures_list').length) { ev.preventDefault(); ev.stopPropagation(); 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 33b8896b29d..0ab8d4c0570 100644 --- a/addons/web/static/src/js/views/graph/graph_model.js +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -25,12 +25,18 @@ return AbstractModel.extend({ //-------------------------------------------------------------------------- /** - * override * + * We defend against outside modifications by extending the chart data. It + * may be overkill. + * + * @override * @returns {Object} */ get: function () { - return this.chart; + var self = this; + return _.extend({}, this.chart, { + comparisonFieldIndex: self._getComparisonFieldIndex(), + }); }, /** * Initial loading. @@ -44,7 +50,9 @@ return AbstractModel.extend({ * @param {Object} params.fields * @param {string[]} params.comparisonTimeRange * @param {string[]} params.domain - * @param {string[]} params.groupBy a list of valid field names + * @param {string[]} params.groupBys a list of valid field names + * @param {string[]} params.groupedBy a list of valid field names + * @param {boolean} params.stacked * @param {string[]} params.timeRange * @param {string} params.comparisonField * @param {string} params.comparisonTimeRangeDescription @@ -72,6 +80,7 @@ return AbstractModel.extend({ measure: params.context.graph_measure || params.measure, mode: params.context.graph_mode || params.mode, origins: [], + stacked: params.stacked, timeRange: params.timeRange, timeRangeDescription: params.timeRangeDescription, }; @@ -86,6 +95,7 @@ return AbstractModel.extend({ * * @param {any} handle ignored! * @param {Object} params + * @param {boolean} [params.stacked] * @param {Object} [params.context] * @param {string[]} [params.domain] * @param {string[]} [params.groupBy] @@ -129,6 +139,10 @@ return AbstractModel.extend({ this.chart.mode = params.mode; return Promise.resolve(); } + if ('stacked' in params) { + this.chart.stacked = params.stacked; + return Promise.resolve(); + } return this._loadGraph(this._getDomains()); }, @@ -137,24 +151,35 @@ return AbstractModel.extend({ //-------------------------------------------------------------------------- /** - * @returns {Object[]} + * @private + * @returns {number} + */ + _getComparisonFieldIndex: function () { + var groupBys = this.chart.groupBy.map(function (gb) { + return gb.split(":")[0]; + }); + return groupBys.indexOf(this.chart.comparisonField); + }, + /** + * @private + * @returns {Array[]} */ _getDomains: function () { var domains = [this.chart.domain.concat(this.chart.timeRange)]; - this.chart.origins = [this.chart.timeRangeDescription]; + 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. * + * @private * @param {Array[]} domains * @returns {Promise} */ @@ -200,6 +225,7 @@ return AbstractModel.extend({ * 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 * + * @private * @param {number} originIndex * @param {any} rawData result from the read_group */ @@ -239,6 +265,7 @@ return AbstractModel.extend({ * Helper function (for _processData), turns various values in a usable * string form, that we can display in the interface. * + * @private * @param {any} value value for the field fieldName received by the read_group rpc * @param {string} fieldName * @returns {string} 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 34d0ed4eb80..02fdb102358 100644 --- a/addons/web/static/src/js/views/graph/graph_renderer.js +++ b/addons/web/static/src/js/views/graph/graph_renderer.js @@ -3,27 +3,55 @@ odoo.define('web.GraphRenderer', function (require) { /** * The graph renderer turns the data from the graph model into a nice looking - * svg chart. This code uses the nvd3 library. - * - * Note that we use a custom build for the nvd3, with only the model we actually - * use. + * canvas chart. This code uses the Chart.js library. */ 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 fieldUtils = require('web.field_utils'); var _t = core._t; -var qweb = core.qweb; var DateClasses = dataComparisonUtils.DateClasses; +var qweb = core.qweb; var CHART_TYPES = ['pie', 'bar', 'line']; +var COLORS = ["#1f77b4","#ff7f0e","#aec7e8","#ffbb78","#2ca02c","#98df8a","#d62728", + "#ff9896","#9467bd","#c5b0d5","#8c564b","#c49c94","#e377c2","#f7b6d2", + "#7f7f7f","#c7c7c7","#bcbd22","#dbdb8d","#17becf","#9edae5"]; +var COLOR_NB = COLORS.length; +function hexToRGBA (hex, opacity) { + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + var rgb = result.slice(1, 4).map(function (n) { + return parseInt(n, 16); + }).join(','); + return 'rgba(' + rgb + ',' + opacity + ')'; +} + +// used to format values in tooltips and yAxes. +var FORMAT_OPTIONS = { + // 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; + }, +}; + + +var NO_DATA = [_t('No data')]; +NO_DATA.isNoData = true; + // hide top legend when too many items for device size -var MAX_LEGEND_LENGTH = 25 * (Math.max(1, config.device.size_class)); -var SPLIT_THRESHOLD = config.device.isMobile ? Infinity : 20; +var MAX_LEGEND_LENGTH = 4 * (Math.max(1, config.device.size_class)); +var MAX_TOOLTIPS_LENGTH = 4 * (Math.max(1, config.device.size_class)); return AbstractRenderer.extend({ className: "o_graph_renderer", @@ -32,38 +60,28 @@ return AbstractRenderer.extend({ * @param {Widget} parent * @param {Object} state * @param {Object} params - * @param {boolean} params.stacked + * @param {boolean} [params.isEmbedded] + * @param {Object} [params.fields] + * @param {string} [params.title] */ init: function (parent, state, params) { this._super.apply(this, arguments); - this.datasets = []; - this.isEmbedded = params.isEmbedded; - this.stacked = this.state.compare ? false : params.stacked; + this.isEmbedded = params.isEmbedded || false; 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; - } - }; + + this.chart = null; + this.chartId = _.uniqueId('chart'); }, /** * @override */ - destroy: function () { - nv.utils.offWindowResize(this.to_remove); - this._super(); + updateState: function () { + + return this._super.apply(this, arguments); }, /** - * The graph view uses the nv(d3) lib to render the graph. This lib requires + * The graph view uses the Chart.js lib to render the graph. This lib requires * that the rendering is done directly into the DOM (so that it can correctly * compute positions). However, the views are always rendered in fragments, * and appended to the DOM once ready (to prevent them from flickering). We @@ -87,38 +105,502 @@ return AbstractRenderer.extend({ }, //-------------------------------------------------------------------------- - // Public + // Private //-------------------------------------------------------------------------- /** - * @override - * @param {Object} state - * @param {Object} params + * Filter out some dataPoints because they would lead to bad graphics. + * The filtering is done with respect to the graph view mode. + * Note that the method does not alter this.state.dataPoints, since we + * want to be able to change of mode without fetching data again: + * we simply present the same data in a different way. + * + * @private + * @returns {Object[]} */ - updateState: function (state, params) { - this.stacked = this.state.compare ? false : params.stacked; - return this._super.apply(this, arguments); + _filterDataPoints: function () { + var dataPoints = []; + if (_.contains(['bar', 'pie'], this.state.mode)) { + dataPoints = this.state.dataPoints.filter(function (dataPt) { + return dataPt.count > 0; + }); + } else if (this.state.mode === 'line') { + var counts = 0; + this.state.dataPoints.forEach(function (dataPt) { + if (dataPt.labels[0] !== _t("Undefined")) { + dataPoints.push(dataPt); + } + counts += dataPt.count; + }); + // data points with zero count might have been created on purpose + // we only remove them if there are no data point with positive count + if (counts === 0) { + dataPoints = []; + } + } + return dataPoints; }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - /** - * Used to format correctly the value in tooltips and y axis + * Used to avoid too long legend items + * + * @private + * @param {string} label + * @returns {string} shortened version of the input label + */ + _shortenLabel: function (label) { + // string returned could be 'wrong' if a groupby value contain a '/'! + var groups = label.split("/"); + var shortLabel = groups.slice(0,3).join("/"); + if (groups.length > 3) { + shortLabel = shortLabel + '/...'; + } + return shortLabel; + }, + /** + * Used to format correctly the values in tooltips and yAxes * * @private * @param {number} value - * @returns {strin} The number formatted + * @returns {string} The value formatted using fieldUtils.format.float */ _formatValue: function (value) { - return field_utils.format.float(value, this.formatOption); + var measureField = this.fields[this.state.measure]; + var formatter = fieldUtils.format.float; + var formatedValue = formatter(value, measureField, FORMAT_OPTIONS); + return formatedValue; + }, + /** + * Used any time we need a new color in our charts. + * + * @private + * @param {number} index + * @returns {string} a color in HEX format + */ + _getColor: function (index) { + return COLORS[index % COLOR_NB]; + }, + /** + * Determines the initial section of the labels array + * over a dataset has to be completed. The section only depends + * on the datasets origins. + * + * @private + * @param {number} originIndex + * @param {number} defaultLength + * @returns {number} + */ + _getDatasetDataLength: function (originIndex, defaultLength) { + if (_.contains(['bar', 'line'], this.state.mode) && this.state.comparisonFieldIndex === 0) { + return this.dateClasses.dateSets[originIndex].length; + } + return defaultLength; + }, + /** + * Determines to which dataset belong the data point + * + * @private + * @param {Object} dataPt + * @returns {string} + */ + _getDatasetLabel:function (dataPt) { + if (_.contains(['bar', 'line'], this.state.mode)) { + // ([origin] + second to last groupBys) or measure + var datasetLabel = dataPt.labels.slice(1).join("/"); + if (this.state.origins.length > 1) { + datasetLabel = this.state.origins[dataPt.originIndex] + + (datasetLabel ? ('/' + datasetLabel) : ''); + } + datasetLabel = datasetLabel || this.fields[this.state.measure].string; + return datasetLabel; + } + return this.state.origins[dataPt.originIndex]; + }, + /** + * Returns a DateClasses instance used to manage equivalence of dates. + * + * @private + * @param {Object[]} dataPoints + * @returns {DateClasses} + */ + _getDateClasses: function (dataPoints) { + var self = this; + var dateSets = this.state.origins.map(function () { + return []; + }); + dataPoints.forEach(function (dataPt) { + dateSets[dataPt.originIndex].push(dataPt.labels[self.state.comparisonFieldIndex]); + }); + dateSets = dateSets.map(function (dateSet) { + return _.uniq(dateSet); + }); + return new DateClasses(dateSets); + }, + /** + * Determines over which label is the data point + * + * @private + * @param {Object} dataPt + * @returns {Array} + */ + _getLabel: function (dataPt) { + var i = this.state.comparisonFieldIndex; + if (_.contains(['bar', 'line'], this.state.mode)) { + if (i === 0) { + return [this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i])]; + } else { + return dataPt.labels.slice(0, 1); + } + } else if (i >= 0) { + return Array.prototype.concat.apply([], [ + dataPt.labels.slice(0, i), + this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i]), + dataPt.labels.slice(i+1) + ]); + } else { + return dataPt.labels; + } + }, + /** + * Returns the options used to generate the chart legend. + * + * @private + * @param {number} datasetsCount + * @returns {Object} + */ + _getLegendOptions: function (datasetsCount) { + var legendOptions = { + display: datasetsCount <= MAX_LEGEND_LENGTH, + position: config.device.size_class > config.device.SIZES.VSM ? 'right' : 'top', + }; + var self = this; + if (_.contains(['bar', 'line'], this.state.mode)) { + var referenceColor; + if (this.state.mode === 'bar') { + referenceColor = 'backgroundColor'; + } else { + referenceColor = 'borderColor'; + } + legendOptions.labels = { + generateLabels: function(chart) { + var data = chart.data; + return data.datasets.map(function(dataset, i) { + return { + text: self._shortenLabel(dataset.label), + fillStyle: dataset[referenceColor], + hidden: !chart.isDatasetVisible(i), + lineCap: dataset.borderCapStyle, + lineDash: dataset.borderDash, + lineDashOffset: dataset.borderDashOffset, + lineJoin: dataset.borderJoinStyle, + lineWidth: dataset.borderWidth, + strokeStyle: dataset[referenceColor], + pointStyle: dataset.pointStyle, + datasetIndex: i, + }; + }); + }, + }; + } else { + legendOptions.labels = { + generateLabels: function(chart) { + var data = chart.data; + var metaData = data.datasets.map(function (dataset, index) { + return chart.getDatasetMeta(index).data; + }); + return data.labels.map(function(label, i) { + var hidden = metaData.reduce( + function (hidden, data) { + if (data[i]) { + hidden = hidden || data[i].hidden; + } + return hidden; + }, + false + ); + var text = self._shortenLabel(self._relabelling(label)); + return { + text: text, + fillStyle: label.isNoData ? '#d3d3d3' : self._getColor(i), + hidden: hidden, + index: i, + }; + }); + }, + }; + } + return legendOptions; + }, + /** + * Returns the options used to generate the chart axes. + * + * @private + * @returns {Object} + */ + _getScaleOptions: function () { + var self = this; + if (_.contains(['bar', 'line'], this.state.mode)) { + return { + xAxes: [{ + type: 'category', + scaleLabel: { + display: this.state.groupBy.length && !this.isEmbedded, + labelString: this.state.groupBy.length ? + this.fields[this.state.groupBy[0].split(':')[0]].string : '', + }, + ticks: { + // don't use bind: callback is called with 'index' as second parameter + // with value labels.indexOf(label)! + callback: function (label) { + return self._relabelling(label); + }, + }, + }], + yAxes: [{ + type: 'linear', + scaleLabel: { + display: !this.isEmbedded, + labelString: this.fields[this.state.measure].string, + }, + stacked: this.state.mode === 'bar' && this.state.stacked, + ticks: { + callback: this._formatValue.bind(this), + suggestedMin: 0, + } + }], + }; + } + return {}; + }, + /** + * Returns the options used to generate chart tooltips. + * + * @private + * @param {number} datasetsCount + * @returns {Object} + */ + _getTooltipOptions: function (datasetsCount) { + var self = this; + var tooltipOptions = { + bodyFontColor: 'rgba(0,0,0,1)', + titleFontSize: 13, + titleFontColor: 'rgba(0,0,0,1)', + backgroundColor: 'rgba(255,255,255,0.6)', + borderColor: 'rgba(0,0,0,0.2)', + borderWidth: 1, + callbacks: { + title: function () { + return self.fields[self.state.measure].string; + }, + }, + }; + if (_.contains(['bar', 'line'], this.state.mode)) { + var referenceColor; + if (this.state.mode === 'bar') { + referenceColor = 'backgroundColor'; + } else { + referenceColor = 'borderColor'; + // avoid too long tooltips + var adaptMode = datasetsCount > MAX_TOOLTIPS_LENGTH || this.isEmbedded; + tooltipOptions = _.extend(tooltipOptions, { + mode: adaptMode ? 'nearest' : 'index', + intersect: false, + toolitemSort: function (tooltipItem1, tooltipItem2) { + return tooltipItem2.yLabel - tooltipItem1.yLabel; + }, + }); + } + tooltipOptions.callbacks = _.extend(tooltipOptions.callbacks, { + label: function (tooltipItem, data) { + var dataset = data.datasets[tooltipItem.datasetIndex]; + var label = data.labels[tooltipItem.index]; + label = self._relabelling(label, dataset.originIndex); + if (self.state.groupBy.length > 1 || self.state.origins.length > 1) { + label = label + "/" + dataset.label; + } + label = label + ': ' + self._formatValue(tooltipItem.yLabel); + return label; + }, + labelColor: function (tooltipItem, chart) { + var dataset = chart.data.datasets[tooltipItem.datasetIndex]; + var tooltipBackgroundColor = dataset[referenceColor]; + var tooltipBorderColor = chart.tooltip._model.backgroundColor; + return { + borderColor: tooltipBorderColor, + backgroundColor: tooltipBackgroundColor, + }; + }, + }); + } else { + tooltipOptions.callbacks = _.extend(tooltipOptions.callbacks, { + label: function (tooltipItem, data) { + var dataset = data.datasets[tooltipItem.datasetIndex]; + var label = data.labels[tooltipItem.index]; + if (label === _t('No data')) { + return dataset.label + "/" + label + ': ' + self._formatValue(0); + } else { + label = self._relabelling(label, dataset.originIndex); + } + if (self.state.origins.length > 1) { + label = dataset.label + "/" + label; + } + label = label + ': ' + self._formatValue(dataset.data[tooltipItem.index]); + return label; + }, + labelColor: function (tooltipItem, chart) { + var dataset = chart.data.datasets[tooltipItem.datasetIndex]; + var tooltipBackgroundColor = dataset.backgroundColor[tooltipItem.index]; + var tooltipBorderColor = chart.tooltip._model.backgroundColor; + return { + borderColor: tooltipBorderColor, + backgroundColor: tooltipBackgroundColor, + }; + }, + }); + } + return tooltipOptions; + }, + /** + * Return the first index of the array list where label can be found + * or -1. + * + * @private + * @param {Array[]} list + * @param {Array} label + * @returns {number} + */ + _indexOf: function (list, label) { + var index = -1; + for (var j = 0; j < list.length; j++) { + var otherLabel = list[j]; + if (label.length === otherLabel.length) { + var equal = true; + for (var i = 0; i < label.length; i++) { + if (label[i] !== otherLabel[i]) { + equal = false; + } + } + if (equal) { + index = j; + break; + } + } + } + return index; + }, + /** + * Separate dataPoints comming from the read_group(s) into different datasets. + * This function returns the parameters data and labels used to produce the charts. + * + * @private + * @param {Object[]} dataPoints + * @param {function} getLabel, + * @param {function} getDatasetLabel, determines to which dataset belong a given data point + * @param {function} [getDatasetDataLength], determines the initial section of the labels array + * over which the datasets have to be completed. These sections only depend + * on the datasets origins. Default is the constant function _ => labels.length. + * @returns {Object} the paramater data used to instatiate the chart. + */ + _prepareData: function (dataPoints) { + var self = this; + + var labels = dataPoints.reduce( + function (acc, dataPt) { + var label = self._getLabel(dataPt); + var index = self._indexOf(acc, label); + if (index === -1) { + acc.push(label); + } + return acc; + }, + [] + ); + + var newDataset = function (datasetLabel, originIndex) { + var data = new Array(self._getDatasetDataLength(originIndex, labels.length)).fill(0); + return { + label: datasetLabel, + data: data, + originIndex: originIndex, + }; + }; + + // dataPoints --> datasets + var datasets = _.values(dataPoints.reduce( + function (acc, dataPt) { + var datasetLabel = self._getDatasetLabel(dataPt); + if (!(datasetLabel in acc)) { + acc[datasetLabel] = newDataset(datasetLabel, dataPt.originIndex); + } + var label = self._getLabel(dataPt); + var labelIndex = self._indexOf(labels, label); + acc[datasetLabel].data[labelIndex] = dataPt.value; + return acc; + }, + {} + )); + + // sort by origin + datasets = datasets.sort(function (dataset1, dataset2) { + return dataset1.originIndex - dataset2.originIndex; + }); + + return { + datasets: datasets, + labels: labels, + }; }, /** - * Render the chart. + * Prepare options for the chart according to the current mode (= chart type). + * This function returns the parameter options used to instantiate the chart + * + * @private + * @param {number} datasetsCount + * @returns {Object} the chart options used for the current mode + */ + _prepareOptions: function (datasetsCount) { + return { + maintainAspectRatio: false, + scales: this._getScaleOptions(), + legend: this._getLegendOptions(datasetsCount), + tooltips: this._getTooltipOptions(datasetsCount), + }; + }, + /** + * Determine how to relabel a label according to a given origin. + * The idea is that the getLabel function is in general not invertible but + * it is when restricted to the set of dataPoints comming from a same origin. + + * @private + * @param {Array} label + * @param {Array} originIndex + * @returns {string} + */ + _relabelling: function (label, originIndex) { + if (label.isNoData) { + return label[0]; + } + var i = this.state.comparisonFieldIndex; + if (_.contains(['bar', 'line'], this.state.mode) && i === 0) { + // here label is an array of length 1 and contains a number + return this.dateClasses.representative(label, originIndex) || ''; + } else if (this.state.mode === 'pie' && i >= 0) { + // here label is an array of length at least one containing string or numbers + var labelCopy = label.slice(0); + if (originIndex !== undefined) { + labelCopy.splice(i, 1, this.dateClasses.representative(label[i], originIndex)); + } else { + labelCopy.splice(i, 1, this.dateClasses.dateClassMembers(label[i])); + } + return labelCopy.join('/'); + } + // here label is an array containing strings or numbers. + return label.join('/') || _t('Total'); + }, + /** + * Render the chart or display a message error in case data is not good enough. * * Note that This method is synchronous, but the actual rendering is done - * asynchronously. The reason for that is that nvd3/d3 needs to be in the + * asynchronously. The reason for that is that Chart.js needs to be in the * DOM to correctly render itself. So, we trick Odoo by returning * immediately, then we render the chart when the widget is in the DOM. * @@ -127,17 +609,18 @@ return AbstractRenderer.extend({ * @returns {Promise} The _super promise is actually resolved immediately */ _render: function () { - if (this.to_remove) { - nv.utils.offWindowResize(this.to_remove); + if (this.chart) { + this.chart.destroy(); } + this.$el.empty(); if (!_.contains(CHART_TYPES, this.state.mode)) { - this.$el.empty(); this.trigger_up('warning', { title: _t('Invalid mode for chart'), message: _t('Cannot render chart with mode : ') + this.state.mode }); - } else if (!this.state.dataPoints.length && this.state.mode !== 'pie') { - this.$el.empty(); + } + var dataPoints = this._filterDataPoints(); + if (!dataPoints.length && this.state.mode !== 'pie') { this.$el.append(qweb.render('GraphView.error', { title: _t("No data to display"), description: _t("Try to add some records, or make sure that " + @@ -148,238 +631,133 @@ return AbstractRenderer.extend({ // happens typically after an update), otherwise, it will be // rendered when the widget will be attached to the DOM (see // 'on_attach_callback') - this._renderGraph(); + var $canvasContainer = $('<div/>', {class: 'o_graph_canvas_container'}); + var $canvas = $('<canvas/>').attr('id', this.chartId); + $canvasContainer.append($canvas); + this.$el.append($canvasContainer); + + var i = this.state.comparisonFieldIndex; + if (i === 0 || (i > 0 && this.state.mode === 'pie')) { + this.dateClasses = this._getDateClasses(dataPoints); + } + if (this.state.mode === 'bar') { + this._renderBarChart(dataPoints); + } else if (this.state.mode === 'line') { + this._renderLineChart(dataPoints); + } else if (this.state.mode === 'pie') { + this._renderPieChart(dataPoints); + } + + this._renderTitle(); } return this._super.apply(this, arguments); }, - - _prepareDataSets: function (dataPoints) { + /** + * create bar chart. + * + * @private + * @param {Object[]} dataPoints + */ + _renderBarChart: 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]; + // style rectangles + Chart.defaults.global.elements.rectangle.borderWidth = 1; + + // prepare data + var data = this._prepareData(dataPoints); + data.datasets.forEach(function (dataset, index) { + // used when stacked + dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined; + // set dataset color + var color = self._getColor(index); + dataset.backgroundColor = color; }); - // 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; + // prepare options + var options = this._prepareOptions(data.datasets.length); - 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 - }; + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'bar', + data: data, + options: options, }); - - return { - datasets: datasets, - ticksLabels: xRange, - groupInfo: groupInfo, - }; }, - /** - * Helper function to set up data properly for the multiBarChart model in - * nvd3. + * create line chart. * - * @returns {nvd3 chart} + * @private + * @param {Object[]} dataPoints */ - _renderBarChart: function () { + _renderLineChart: function (dataPoints) { var self = this; - // 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'; + // style lines + Chart.defaults.global.elements.line.tension = 0; + Chart.defaults.global.elements.line.fill = false; + + // prepare data + var data = this._prepareData(dataPoints); + data.datasets.forEach(function (dataset, index) { + if (self.state.groupBy.length <= 1 && self.state.origins.length > 1) { + if (dataset.originIndex === 0) { + dataset.fill = 'origin'; + dataset.backgroundColor = hexToRGBA(COLORS[0], 0.4); + dataset.borderColor = hexToRGBA(COLORS[0], 1); + } else if (dataset.originIndex === 1) { + dataset.borderColor = hexToRGBA(COLORS[1], 1); + } else { + dataset.borderColor = self._getColor(index); } - }); - } - - // nvd3 specific - var $svgContainer = $('<div/>', {class: 'o_graph_svg_container'}); - this.$el.append($svgContainer); - var svg = d3.select($svgContainer[0]).append('svg'); - svg.datum(this.datasets); - - svg.transition().duration(0); - - var chart = nv.models.multiBarChart(); - chart.options({ - margin: {left: 80, bottom: 100, top: 80, right: 0}, - delay: 100, - transition: 10, - controlLabels: { - 'grouped': _t('Grouped'), - 'stacked': _t('Stacked'), - }, - showLegend: _.size(this.datasets) <= MAX_LEGEND_LENGTH, - showXAxis: true, - showYAxis: true, - rightAlignYAxis: false, - stacked: this.stacked, - reduceXTicks: false, - rotateLabels: -20, - showControls: (this.state.groupBy.length > 1) + } else { + dataset.borderColor = self._getColor(index); + } + if (data.labels.length === 1) { + // decalage of the real value to right. This is done to center the points in the chart + // See data.labels below in Chart parameters + dataset.data.unshift(undefined); + } + dataset.pointBackgroundColor = dataset.borderColor; + dataset.pointBorderColor = 'rgba(0,0,0,0.2)'; }); - - chart.yAxis - .tickFormat(function (d) { - return self._formatValue(d); - }); - - - chart.tooltip.contentGenerator(function (data) { - var lines = data.series.map(function (serie) { - 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: self._formatValue(serie.value), - }; - }); - return qweb.render("web.Chart.Tooltip", { - title: self.fields[self.state.measure].string, - lines: lines.sort(function (line1, line2) { - return line2.value - line1.value; - }), - }); + // center the points in the chart (whithout that code they are put on the left and the graph seems empty) + data.labels = data.labels.length > 1 ? + data.labels : + Array.prototype.concat.apply([], [[['']], data.labels ,[['']]]); + + // prepare options + var options = this._prepareOptions(data.datasets.length); + + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'line', + data: data, + options: options, }); - - chart(svg); - return chart; }, /** - * Helper function to set up data properly for the pieChart model in - * nvd3. + * create pie chart * - * returns undefined in the case of an non-embedded pie chart with no data. - * (all zero data included) - *. - * @returns {nvd3 chart|undefined} + * @private + * @param {Object[]} dataPoints */ - _renderPieChart: function (originIndex) { + _renderPieChart: function (dataPoints) { var self = this; - var all_negative = true; - var some_negative = false; - var all_zero = true; - var dataPoints = this.state.dataPoints.filter(function (dataPt) { - return dataPt.originIndex === originIndex && dataPt.count > 0; - }); + // try to see if some pathologies are still present after the filtering + var allNegative = true; + var someNegative = false; + var allZero = true; 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); + allNegative = allNegative && (datapt.value < 0); + someNegative = someNegative || (datapt.value < 0); + allZero = allZero && (datapt.value === 0); }); - if (some_negative && !all_negative) { + if (someNegative && !allNegative) { + this.$el.empty(); this.$el.append(qweb.render('GraphView.error', { title: _t("Invalid data"), description: _t("Pie chart cannot mix positive and negative numbers. " + @@ -387,235 +765,83 @@ return AbstractRenderer.extend({ })); return; } - if (all_zero) { - if (this.isEmbedded || this.state.compare) { - // add fake data to display an empty pie chart - this.datasets = [{ - x : "No data" , - y : 1 - }]; - } else { - this.$el.append(qweb.render('GraphView.error', { - title: _t("Invalid data"), - description: _t("Pie chart cannot display all zero numbers.. " + - "Try to change your domain to display positive results"), - })); - return; - } - } else { - 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(this.datasets); - - svg.transition().duration(100); - - var color; - var legend_right = config.device.size_class > config.device.SIZES.VSM; - if (all_zero) { - color = (['lightgrey']); - svg.append("text") - .attr("text-anchor", "middle") - .attr("x", "50%") - .attr("y", "50%") - .text(_t("No data to display")); - } else { - color = d3.scale.category10().range(); + if (allZero && !this.isEmbedded && this.state.origins.length === 1) { + this.$el.empty(); + this.$el.append(qweb.render('GraphView.error', { + title: _t("Invalid data"), + description: _t("Pie chart cannot display all zero numbers.. " + + "Try to change your domain to display positive results"), + })); + return; } - var chart = nv.models.pieChart().labelType('percent'); - chart.options({ - delay: 250, - showLegend: !all_zero && (legend_right || _.size(this.datasets) <= MAX_LEGEND_LENGTH), - legendPosition: legend_right ? 'right' : 'top', - transition: 100, - color: color, - showLabels: all_zero ? false: true, - }); - - chart.tooltip.contentGenerator(function (data) { - return qweb.render("web.Chart.Tooltip", { - title: self.fields[self.state.measure].string, - lines : [{ - color: data.color, - label: data.data.x, - value: self._formatValue(data.data.y), - }], - }); - }); - - chart(svg); - return chart; - }, - /** - * Helper function to set up data properly for the line model in - * nvd3. - * - * @returns {nvd3 chart} - */ - _renderLineChart: function () { - var self = this; - - // remove some data points - var dataPoints = this.state.dataPoints.filter(function (dataPt) { - return dataPt.labels[0] !== _t("Undefined"); - }); - - var dataProcessed = this._prepareDataSets(dataPoints); - var ticksLabels = dataProcessed.ticksLabels; - 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 === 0) { - group.area = true; - } - if (groupInfo[group.key].originIndex === 1) { - group.color = '#ff7f0e'; - } - }); - } - this.datasets.forEach(function (group) { - group.values = group.values.map(function (value) { + // prepare data + var data = {}; + var colors = []; + if (allZero) { + // add fake data to display a pie chart with a grey zone associated + // with every origin + data.labels = [NO_DATA]; + data.datasets = this.state.origins.map(function (origin) { return { - x: ticksLabels.indexOf(value.x), - y: value.y + label: origin, + data: [1], + backgroundColor: ['#d3d3d3'], }; }); - }); - - 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 (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(this.datasets); - - svg.transition().duration(0); - - var chart = nv.models.lineChart(); - chart.options({ - margin: {left: 0, bottom: 20, top: 0, right: 0}, - useInteractiveGuideline: true, - showLegend: _.size(this.datasets) <= MAX_LEGEND_LENGTH, - showXAxis: true, - showYAxis: true, - stacked: true, - }); - chart.forceY([0]); - chart.xAxis - .tickFormat(function (d) { - return ticksLabels[d]; + } else { + data = this._prepareData(dataPoints); + // give same color to same groups from different origins + colors = data.labels.map(function (label, index) { + return self._getColor(index); }); - chart.yAxis - .showMaxMin(false) - .tickFormat(function (d) { - return self._formatValue(d); + data.datasets.forEach(function (dataset) { + dataset.backgroundColor = colors; + dataset.borderColor = 'rgba(255,255,255,0.6)'; }); - chart.yAxis.tickPadding(5); - chart.yAxis.orient("right"); - - chart.interactiveLayer.tooltip.contentGenerator(function (data) { - 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: self._formatValue(serie.value), - }; + // make sure there is a zone associated with every origin + var representedOriginIndexes = data.datasets.map(function (dataset) { + return dataset.originIndex; }); - return qweb.render("web.Chart.Tooltip", { - title: self.fields[self.state.measure].string, - lines: lines.sort(function (line1, line2) { - return line2.value - line1.value; - }), + var addNoDataToLegend = false; + var fakeData = (new Array(data.labels.length)).concat([1]); + this.state.origins.forEach(function (origin, originIndex) { + if (!_.contains(representedOriginIndexes, originIndex)) { + data.datasets.splice(originIndex, 0, { + label: origin, + data: fakeData, + backgroundColor: colors.concat(['#d3d3d3']), + }); + addNoDataToLegend = true; + } }); - }); - - 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'); - - // Delete first and last label because there is no enough space because - // of the tiny margins. - if (ticksLabels.length > 3) { - $svgContainer.find('svg .nv-x g.nv-axisMaxMin-x > text').hide(); + if (addNoDataToLegend) { + data.labels.push(NO_DATA); + } } - return chart; + // prepare options + var options = this._prepareOptions(data.datasets.length); + + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'pie', + data: data, + options: options, + }); }, /** - * Renders the graph according to its type. This function must be called - * when the renderer is in the DOM (for nvd3 to render the graph correctly). + * Add the graph title (if any) above the canvas * * @private */ - _renderGraph: function () { - var self = this; - - this.$el.empty(); - - 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'](0); - - if (chart) { - chart.dispatch.on('renderEnd', function () { - // FIXME: When 'orient' is right for Y axis, horizontal lines aren't displayed correctly - $('.nv-y .tick > line').attr('x2', function (i, value) { - return Math.abs(value); - }); - }); - chartResize(chart); - } - - 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/>', { - text: chartTitle, - })); - - // Instantiate comparison graph - 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/>', { - text: comparisonChartTitle, - })); - chartResize(comparisonChart); - if (chart) { - chart.update(); - } - } else if (this.title) { - this.$('.o_graph_svg_container').last().prepend($('<label/>', { + _renderTitle: function () { + if (this.title) { + this.$('.o_graph_canvas_container').last().prepend($('<label/>', { text: this.title, })); } }, }); - }); 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 e731835bc30..f953c488a09 100644 --- a/addons/web/static/src/js/views/graph/graph_view.js +++ b/addons/web/static/src/js/views/graph/graph_view.js @@ -22,13 +22,8 @@ var GROUPABLE_TYPES = var GraphView = AbstractView.extend({ display_name: _lt('Graph'), icon: 'fa-bar-chart', - cssLibs: [ - '/web/static/lib/nvd3/nv.d3.css' - ], jsLibs: [ - '/web/static/lib/nvd3/d3.v3.js', - '/web/static/lib/nvd3/nv.d3.js', - '/web/static/src/js/libs/nvd3.js' + '/web/static/lib/Chart/Chart.js', ], config: { Model: GraphModel, @@ -80,7 +75,6 @@ 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'; @@ -88,6 +82,7 @@ var GraphView = AbstractView.extend({ this.loadParams.groupBys = groupBys; this.loadParams.fields = this.fields; this.loadParams.comparisonDomain = params.comparisonDomain; + this.loadParams.stacked = this.arch.attrs.stacked !== "False"; }, }); |
