summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src/js')
-rw-r--r--addons/web/static/src/js/core/data_comparison_utils.js106
-rw-r--r--addons/web/static/src/js/views/graph/graph_controller.js23
-rw-r--r--addons/web/static/src/js/views/graph/graph_model.js39
-rw-r--r--addons/web/static/src/js/views/graph/graph_renderer.js1160
-rw-r--r--addons/web/static/src/js/views/graph/graph_view.js9
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";
},
});