summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views/graph/graph_renderer.js
diff options
context:
space:
mode:
authorMathieu Duckerts-Antoine <dam@odoo.com>2019-02-26 14:43:23 +0000
committerMathieu Duckerts-Antoine <dam@odoo.com>2019-04-01 14:40:51 +0000
commit604c43640a05ea30e085381c90584f31e8bf7f7c (patch)
tree8eb67b4911c04f59a6cb537b9b920a017bd8ca85 /addons/web/static/src/js/views/graph/graph_renderer.js
parenta8d3ca771f0d086767ed45a198db37ad3ebce029 (diff)
[IMP] web: use Chart.js in graph view
It has been decided to use Chart.js instead of nvd3 to render charts. In this commit the graph view has been largely rewritten to benefit from the options offered by Chart.js. Task ID: 1946138
Diffstat (limited to 'addons/web/static/src/js/views/graph/graph_renderer.js')
-rw-r--r--addons/web/static/src/js/views/graph/graph_renderer.js1160
1 files changed, 693 insertions, 467 deletions
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,
}));
}
},
});
-
});