diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py
index 0127c40e1ce..110ba18354d 100644
--- a/addons/web/__manifest__.py
+++ b/addons/web/__manifest__.py
@@ -35,6 +35,7 @@ This module provides the core of the Odoo Web Client.
"static/src/xml/report.xml",
"static/src/xml/search_panel.xml",
"static/src/xml/web_calendar.xml",
+ "static/src/xml/graph.xml",
],
'bootstrap': True, # load translations for login screen
}
diff --git a/addons/web/static/src/js/views/abstract_renderer.js b/addons/web/static/src/js/views/abstract_renderer.js
index 5e72a4827b7..73d9c81da3a 100644
--- a/addons/web/static/src/js/views/abstract_renderer.js
+++ b/addons/web/static/src/js/views/abstract_renderer.js
@@ -134,7 +134,7 @@ return mvc.Renderer.extend({
},
/**
* @private
- * @param {Object} context
+ * @param {Object} [context]
*/
_renderNoContentHelper: function (context) {
let templateName;
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 5751c5b5805..ac5eb343322 100644
--- a/addons/web/static/src/js/views/graph/graph_controller.js
+++ b/addons/web/static/src/js/views/graph/graph_controller.js
@@ -53,13 +53,6 @@ var GraphController = AbstractController.extend({
this.groupableFields = params.groupableFields;
this.buttonDropdownPromises = [];
},
- /**
- * @override
- */
- start: function () {
- this.$el.addClass('o_graph_controller');
- return this._super.apply(this, arguments);
- },
/**
* @todo check if this can be removed (mostly duplicate with
* AbstractController method)
@@ -130,16 +123,16 @@ var GraphController = AbstractController.extend({
items: this.measures,
});
this.buttonDropdownPromises = [this.measureMenu.mount(fragment)];
+ if (this.isEmbedded) {
+ // Instantiate and append GroupBy menu
+ this.groupByMenu = new ComponentWrapper(this, CarretDropdownMenu, {
+ title: "Group By",
+ icon: 'fa fa-bars',
+ items: this._getGroupBys(state.groupBy),
+ });
+ this.buttonDropdownPromises.push(this.groupByMenu.mount(fragment));
+ }
if ($node) {
- if (this.isEmbedded) {
- // Instantiate and append GroupBy menu
- this.groupByMenu = new ComponentWrapper(this, CarretDropdownMenu, {
- title: "Group By",
- icon: 'fa fa-bars',
- items: this._getGroupBys(state.groupBy),
- });
- this.buttonDropdownPromises.push(this.groupByMenu.mount(fragment));
- }
this.$buttons.appendTo($node);
}
}
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 118245f997b..3b576485b4c 100644
--- a/addons/web/static/src/js/views/graph/graph_renderer.js
+++ b/addons/web/static/src/js/views/graph/graph_renderer.js
@@ -1,1099 +1,1044 @@
-odoo.define('web.GraphRenderer', function (require) {
-"use strict";
+odoo.define("web/static/src/js/views/graph/graph_renderer", function (require) {
+ "use strict";
-/**
- * The graph renderer turns the data from the graph model into a nice looking
- * canvas chart. This code uses the Chart.js library.
- */
+ const AbstractRenderer = require("web.AbstractRendererOwl");
+ const { device } = require("web.config");
+ const { DateClasses } = require("web.dataComparisonUtils");
+ const fieldUtils = require("web.field_utils");
+ const patchMixin = require("web.patchMixin");
+ const { sortBy } = require("web.utils");
-var AbstractRenderer = require('web.AbstractRenderer');
-var config = require('web.config');
-var core = require('web.core');
-var dataComparisonUtils = require('web.dataComparisonUtils');
-var fieldUtils = require('web.field_utils');
-
-var _t = core._t;
-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 = 4 * (Math.max(1, config.device.size_class));
-
-return AbstractRenderer.extend({
- className: "o_graph_renderer",
- sampleDataTargets: ['.o_graph_canvas_container'],
- /**
- * @override
- * @param {Widget} parent
- * @param {Object} state
- * @param {Object} params
- * @param {boolean} [params.isEmbedded]
- * @param {Object} [params.fields]
- * @param {string} [params.title]
- */
- init: function (parent, state, params) {
- this._super.apply(this, arguments);
- this.isEmbedded = params.isEmbedded || false;
- this.title = params.title || '';
- this.fields = params.fields || {};
- this.disableLinking = params.disableLinking;
-
- this.chart = null;
- this.chartId = _.uniqueId('chart');
- this.$legendTooltip = null;
- this.$tooltip = null;
- },
- /**
- * Chart.js does not need the canvas to be in dom in order
- * to be able to work well. We could avoid the calls to on_attach_callback
- * and on_detach_callback.
- *
- * @override
- */
- on_attach_callback: function () {
- this._super.apply(this, arguments);
- this.isInDOM = true;
- this._render();
- },
- /**
- * @override
- */
- on_detach_callback: function () {
- this._super.apply(this, arguments);
- this.isInDOM = false;
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
+ const COLORS = [
+ "#1f77b4", "#ff7f0e", "#aec7e8", "#ffbb78", "#2ca02c", "#98df8a", "#d62728",
+ "#ff9896", "#9467bd", "#c5b0d5", "#8c564b", "#c49c94", "#e377c2", "#f7b6d2",
+ "#7f7f7f", "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5",
+ ];
+ const DEFAULT_BG = "#d3d3d3";
+ // used to format values in tooltips and yAxes.
+ const FORMAT_OPTIONS = {
+ // allow to decide if utils.human_number should be used
+ humanReadable: value => 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: str => str,
+ };
+ // hide top legend when too many items for device size
+ const MAX_LEGEND_LENGTH = 4 * Math.max(1, device.size_class);
+ const RGB_REGEX = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
+ const { useRef } = owl.hooks;
/**
- * This function aims to remove a suitable number of lines from the tooltip in order to make it reasonably visible.
- * A message indicating the number of lines is added if necessary.
- *
- * @private
- * @param {Number} maxTooltipHeight this the max height in pixels of the tooltip
- */
- _adjustTooltipHeight: function (maxTooltipHeight) {
- var sizeOneLine = this.$tooltip.find('tbody tr')[0].clientHeight;
- var tbodySize = this.$tooltip.find('tbody')[0].clientHeight;
- var toKeep = Math.floor((maxTooltipHeight - (this.$tooltip[0].clientHeight - tbodySize)) / sizeOneLine) - 1;
- var $lines = this.$tooltip.find('tbody tr');
- var toRemove = $lines.length - toKeep;
- if (toRemove > 0) {
- $lines.slice(toKeep).remove();
- var tr = document.createElement('tr');
- var td = document.createElement('td');
- tr.classList.add('o_show_more');
- td.innerHTML = _t("...");
- tr.appendChild(td);
- this.$tooltip.find('tbody').append(tr);
- }
- },
- /**
- * This function creates a custom HTML tooltip.
- *
- * @private
- * @param {Object} tooltipModel see chartjs documentation
- */
- _customTooltip: function (tooltipModel) {
- this.$el.css({ cursor: 'default' });
- if (this.$tooltip) {
- this.$tooltip.remove();
- }
- if (tooltipModel.opacity === 0) {
- return;
- }
- if (tooltipModel.dataPoints.length === 0) {
- return;
- }
-
- if (this._isRedirectionEnabled()) {
- this.$el.css({ cursor: 'pointer' });
- }
-
- const chartArea = this.chart.chartArea;
- const chartAreaLeft = chartArea.left;
- const chartAreaRight = chartArea.right;
- const chartAreaTop = chartArea.top;
- const rendererTop = this.$el[0].getBoundingClientRect().top;
-
- const maxTooltipLabelWidth = Math.floor((chartAreaRight - chartAreaLeft) / 1.68) + 'px';
-
- const tooltipItems = this._getTooltipItems(tooltipModel);
-
- this.$tooltip = $(qweb.render('GraphView.CustomTooltip', {
- measure: this.fields[this.state.measure].string,
- tooltipItems: tooltipItems,
- maxWidth: maxTooltipLabelWidth,
- })).css({top: '2px', left: '2px'});
- const $container = this.$el.find('.o_graph_canvas_container');
- $container.append(this.$tooltip);
-
- let top;
- const tooltipHeight = this.$tooltip[0].clientHeight;
- const minTopAllowed = Math.floor(chartAreaTop);
- const maxTopAllowed = Math.floor(window.innerHeight - rendererTop - tooltipHeight) - 2;
- const y = Math.floor(tooltipModel.y);
- if (minTopAllowed <= maxTopAllowed) {
- // Here we know that the full tooltip can fit in the screen.
- // We put it in the position where Chart.js would put it
- // if two conditions are respected:
- // 1: the tooltip is not cut (because we know it is possible to not cut it)
- // 2: the tooltip does not hide the legend.
- // If it is not possible to use the Chart.js proposition (y)
- // we use the best approximated value.
- if (y <= maxTopAllowed) {
- if (y >= minTopAllowed) {
- top = y;
- } else {
- top = minTopAllowed;
- }
- } else {
- top = maxTopAllowed;
- }
- } else {
- // Here we know that we cannot satisfy condition 1 above,
- // so we position the tooltip at the minimal position and
- // cut it the minimum possible.
- top = minTopAllowed;
- const maxTooltipHeight = window.innerHeight - (rendererTop + chartAreaTop) -2;
- this._adjustTooltipHeight(maxTooltipHeight);
- }
- this.$tooltip[0].style.top = Math.floor(top) + 'px';
-
- this._fixTooltipLeftPosition(this.$tooltip[0], tooltipModel.x);
- },
- /**
- * 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[]}
- */
- _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;
- },
- /**
- * Sets best left position of a tooltip approaching the proposal x
- *
- * @private
- * @param {DOMElement} tooltip
- * @param {number} x, left offset proposed
- */
- _fixTooltipLeftPosition: function (tooltip, x) {
- let left;
- const tooltipWidth = tooltip.clientWidth;
- const minLeftAllowed = Math.floor(this.chart.chartArea.left + 2);
- const maxLeftAllowed = Math.floor(this.chart.chartArea.right - tooltipWidth -2);
- x = Math.floor(x);
- if (x <= maxLeftAllowed) {
- if (x >= minLeftAllowed) {
- left = x;
- } else {
- left = minLeftAllowed;
- }
- } else {
- left = maxLeftAllowed;
- }
- tooltip.style.left = left + 'px';
- },
- /**
- * Used to format correctly the values in tooltips and yAxes
- *
- * @private
- * @param {number} value
- * @returns {string} The value formatted using fieldUtils.format.float
- */
- _formatValue: function (value) {
- 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 an object used to style chart elements independently from the datasets.
- *
- * @private
- * @returns {Object}
- */
- _getElementOptions: function () {
- var elementOptions = {};
- if (this.state.mode === 'bar') {
- elementOptions.rectangle = {borderWidth: 1};
- } else if (this.state.mode === 'line') {
- elementOptions.line = {
- tension: 0,
- fill: false,
- };
- }
- return elementOptions;
- },
- /**
- * 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([], [
- 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: this.state.mode === 'pie' ? 'right' : 'top',
- position: 'top',
- onHover: this._onlegendTooltipHover.bind(this),
- onLeave: this._onLegendTootipLeave.bind(this),
- };
- 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),
- fullText: 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 fullText = self._relabelling(label);
- var text = self._shortenLabel(fullText);
- return {
- text: text,
- fullText: fullText,
- 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.processedGroupBy.length && !this.isEmbedded,
- labelString: this.state.processedGroupBy.length ?
- this.fields[this.state.processedGroupBy[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,
- },
- ticks: {
- callback: this._formatValue.bind(this),
- suggestedMax: 0,
- suggestedMin: 0,
- }
- }],
- };
- }
- return {};
- },
- /**
- * Extracts the important information from a tooltipItem generated by Charts.js
- * (a tooltip item corresponds to a line (different from measure name) of a tooltip)
- *
- * @private
- * @param {Object} item
- * @param {Object} data
- * @returns {Object}
- */
- _getTooltipItemContent: function (item, data) {
- var dataset = data.datasets[item.datasetIndex];
- var label = data.labels[item.index];
- var value;
- var boxColor;
- if (this.state.mode === 'bar') {
- label = this._relabelling(label, dataset.originIndex);
- if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) {
- label = label + "/" + dataset.label;
- }
- value = this._formatValue(item.yLabel);
- boxColor = dataset.backgroundColor;
- } else if (this.state.mode === 'line') {
- label = this._relabelling(label, dataset.originIndex);
- if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) {
- label = label + "/" + dataset.label;
- }
- value = this._formatValue(item.yLabel);
- boxColor = dataset.borderColor;
- } else {
- if (label.isNoData) {
- value = this._formatValue(0);
- } else {
- value = this._formatValue(dataset.data[item.index]);
- }
- label = this._relabelling(label, dataset.originIndex);
- if (this.state.origins.length > 1) {
- label = dataset.label + "/" + label;
- }
- boxColor = dataset.backgroundColor[item.index];
- }
- return {
- label: label,
- value: value,
- boxColor: boxColor,
- };
- },
- /**
- * This function extracts the information from the data points in tooltipModel.dataPoints
- * (corresponding to datapoints over a given label determined by the mouse position)
- * that will be displayed in a custom tooltip.
- *
- * @private
- * @param {Object} tooltipModel see chartjs documentation
- * @return {Object[]}
- */
- _getTooltipItems: function (tooltipModel) {
- var self = this;
- var data = this.chart.config.data;
+ function getColor(index) {
+ return COLORS[index % COLORS.length];
+ }
- var orderedItems = tooltipModel.dataPoints.sort(function (dPt1, dPt2) {
- return dPt2.yLabel - dPt1.yLabel;
- });
- return orderedItems.reduce(
- function (acc, item) {
- acc.push(self._getTooltipItemContent(item, data));
- return acc;
- },
- []
- );
- },
/**
- * Returns the options used to generate chart tooltips.
- *
- * @private
- * @returns {Object}
- */
- _getTooltipOptions: function () {
- var tooltipOptions = {
- // disable Chart.js tooltips
- enabled: false,
- custom: this._customTooltip.bind(this),
- };
- if (this.state.mode === 'line') {
- tooltipOptions.mode = 'index';
- tooltipOptions.intersect = false;
- }
- return tooltipOptions;
- },
- /**
- * Returns true iff the current graph can be clicked on to redirect to the
- * list of records.
- *
- * @private
- * @returns {boolean}
- */
- _isRedirectionEnabled: function () {
- return !this.disableLinking &&
- (this.state.mode === 'bar' || this.state.mode === 'pie');
- },
- /**
- * 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 coming 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 parameter data used to instantiate the chart.
- */
- _prepareData: function (dataPoints) {
- var self = this;
-
- var labelMap = {};
- var labels = dataPoints.reduce(
- function (acc, dataPt) {
- var label = self._getLabel(dataPt);
- var labelKey = dataPt.resId + ':' + JSON.stringify(label);
- var index = labelMap[labelKey];
- if (index === undefined) {
- labelMap[labelKey] = dataPt.labelIndex = acc.length;
- acc.push(label);
- }
- else{
- dataPt.labelIndex = index;
- }
- return acc;
- },
- []
- );
-
- var newDataset = function (datasetLabel, originIndex) {
- var data = new Array(self._getDatasetDataLength(originIndex, labels.length)).fill(0);
- const domain = new Array(self._getDatasetDataLength(originIndex, labels.length)).fill([]);
- return {
- label: datasetLabel,
- data: data,
- domain: domain,
- 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 labelIndex = dataPt.labelIndex;
- acc[datasetLabel].data[labelIndex] = dataPt.value;
- acc[datasetLabel].domain[labelIndex] = dataPt.domain;
- return acc;
- },
- {}
- ));
-
- // sort by origin
- datasets = datasets.sort(function (dataset1, dataset2) {
- return dataset1.originIndex - dataset2.originIndex;
- });
-
- return {
- datasets: datasets,
- labels: labels,
- };
- },
- /**
- * 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) {
- const options = {
- maintainAspectRatio: false,
- scales: this._getScaleOptions(),
- legend: this._getLegendOptions(datasetsCount),
- tooltips: this._getTooltipOptions(),
- elements: this._getElementOptions(),
- };
- if (this._isRedirectionEnabled()) {
- options.onClick = this._onGraphClicked.bind(this);
- }
- return options;
- },
- /**
- * 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 coming from a same origin.
-
- * @private
- * @param {Array} label
- * @param {Array} originIndex
+ * @param {Object} chartArea
* @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');
- },
+ function getMaxWidth({ left, right }) {
+ return Math.floor((right - left) / 1.618) + "px";
+ }
+
/**
- * 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 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.
- *
- * @override
+ * @param {string} hex
+ * @param {number} opacity
+ * @returns {string}
*/
- async _renderView() {
- if (this.chart) {
- this.chart.destroy();
- }
- this.$el.empty();
- if (!_.contains(CHART_TYPES, this.state.mode)) {
- this.trigger_up('warning', {
- title: _t('Invalid mode for chart'),
- message: _t('Cannot render chart with mode : ') + this.state.mode
- });
- }
- var dataPoints = this._filterDataPoints();
- dataPoints = this._sortDataPoints(dataPoints);
- if (this.isInDOM) {
- this._renderTitle();
+ function hexToRGBA(hex, opacity) {
+ const rgb = RGB_REGEX
+ .exec(hex)
+ .slice(1, 4)
+ .map(n => parseInt(n, 16))
+ .join(",");
+ return `rgba(${rgb},${opacity})`;
+ }
- // detect if some pathologies are still present after the filtering
- if (this.state.mode === 'pie') {
- const someNegative = dataPoints.some(dataPt => dataPt.value < 0);
- const somePositive = dataPoints.some(dataPt => dataPt.value > 0);
- if (someNegative && somePositive) {
- const context = {
- title: _t("Invalid data"),
- description: [
- _t("Pie chart cannot mix positive and negative numbers. "),
- _t("Try to change your domain to only display positive results")
- ].join("")
- };
- this._renderNoContentHelper(context);
- return;
- }
- }
-
- if (this.state.isSample && !this.isEmbedded) {
- this._renderNoContentHelper();
- }
-
- // only render the graph if the widget is already in the DOM (this
- // happens typically after an update), otherwise, it will be
- // rendered when the widget will be attached to the DOM (see
- // 'on_attach_callback')
- var $canvasContainer = $('
', {class: 'o_graph_canvas_container'});
- var $canvas = $('').attr('id', this.chartId);
- $canvasContainer.append($canvas);
- this.$el.append($canvasContainer);
-
- var i = this.state.comparisonFieldIndex;
- if (i === 0) {
- 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);
- }
- }
- },
/**
- * create bar chart.
- *
- * @private
- * @param {Object[]} dataPoints
- */
- _renderBarChart: function (dataPoints) {
- var self = this;
-
- // 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;
- });
-
- // prepare options
- var options = this._prepareOptions(data.datasets.length);
-
- // create chart
- var ctx = document.getElementById(this.chartId);
- this.chart = new Chart(ctx, {
- type: 'bar',
- data: data,
- options: options,
- });
- },
- /**
- * create line chart.
- *
- * @private
- * @param {Object[]} dataPoints
- */
- _renderLineChart: function (dataPoints) {
- var self = this;
-
- // prepare data
- var data = this._prepareData(dataPoints);
- data.datasets.forEach(function (dataset, index) {
- if (self.state.processedGroupBy.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);
- }
- } else {
- dataset.borderColor = self._getColor(index);
- }
- if (data.labels.length === 1) {
- // shift 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)';
- });
- if (data.datasets.length === 1) {
- const dataset = data.datasets[0];
- dataset.fill = 'origin';
- dataset.backgroundColor = hexToRGBA(COLORS[0], 0.4);
- }
-
- // center the points in the chart (without 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,
- });
- },
- /**
- * create pie chart
- *
- * @private
- * @param {Object[]} dataPoints
- */
- _renderPieChart: function (dataPoints) {
- var self = this;
- // prepare data
- var data = {};
- var colors = [];
- const allZero = dataPoints.every(dataPt => dataPt.value === 0);
- 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 {
- label: origin,
- data: [1],
- backgroundColor: ['#d3d3d3'],
- };
- });
- } 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);
- });
- data.datasets.forEach(function (dataset) {
- dataset.backgroundColor = colors;
- dataset.borderColor = 'rgba(255,255,255,0.6)';
- });
- // make sure there is a zone associated with every origin
- var representedOriginIndexes = data.datasets.map(function (dataset) {
- return dataset.originIndex;
- });
- 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;
- }
- });
- if (addNoDataToLegend) {
- data.labels.push(NO_DATA);
- }
- }
-
- // 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,
- });
- },
- /**
- * Add the graph title (if any) above the canvas
- *
- * @private
- */
- _renderTitle: function () {
- if (this.title) {
- this.$el.prepend($('', {
- text: this.title,
- }));
- }
- },
- /**
- * Used to avoid too long legend items
- *
- * @private
+ * Used to avoid too long legend items.
* @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("/");
+ function shortenLabel(label) {
+ // string returned could be wrong if a groupby value contain a "/"!
+ const groups = label.split("/");
+ let shortLabel = groups.slice(0, 3).join("/");
if (shortLabel.length > 30) {
- shortLabel = shortLabel.slice(0, 30) + '...';
+ shortLabel = `${shortLabel.slice(0, 30)}...`;
} else if (groups.length > 3) {
- shortLabel = shortLabel + '/...';
+ shortLabel = `${shortLabel}/...`;
}
return shortLabel;
- },
- /**
- * Sort datapoints according to the current order (ASC or DESC).
- *
- * Note: this should be moved to the model at some point.
- *
- * @private
- * @param {Object[]} dataPoints
- * @returns {Object[]} sorted dataPoints if orderby set on state
- */
- _sortDataPoints(dataPoints) {
- if (!Object.keys(this.state.timeRanges).length && this.state.orderBy &&
- ['bar', 'line'].includes(this.state.mode) && this.state.groupBy.length) {
- // group data by their x-axis value, and then sort datapoints
- // based on the sum of values by group in ascending/descending order
- const groupByFieldName = this.state.groupBy[0].split(':')[0];
- const groupedByMany2One = this.fields[groupByFieldName].type === 'many2one';
- const groupedDataPoints = {};
- dataPoints.forEach(function (dataPoint) {
- const key = groupedByMany2One ? dataPoint.resId : dataPoint.labels[0];
- groupedDataPoints[key] = groupedDataPoints[key] || [];
- groupedDataPoints[key].push(dataPoint);
- });
- dataPoints = _.sortBy(groupedDataPoints, function (group) {
- return group.reduce((sum, dataPoint) => sum + dataPoint.value, 0);
- });
- dataPoints = dataPoints.flat();
- if (this.state.orderBy === 'desc') {
- dataPoints = dataPoints.reverse('value');
+ }
+
+ class GraphRenderer extends AbstractRenderer {
+ constructor() {
+ super(...arguments);
+
+ this.noDataLabel = [this.env._t("No data")];
+ this.sampleDataTargets = [".o_graph_canvas_container"];
+ this._processProps(this.props);
+
+ this.canvasRef = useRef("canvas");
+ this.containerRef = useRef("container");
+ }
+
+ async willUpdateProps(nextProps) {
+ await super.willUpdateProps(...arguments);
+ this._processProps(nextProps);
+ }
+
+ mounted() {
+ super.mounted();
+ this._renderChart();
+ }
+
+ patched() {
+ super.patched();
+ this._renderChart();
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * This function aims to remove a suitable number of lines from the
+ * tooltip in order to make it reasonably visible. A message indicating
+ * the number of lines is added if necessary.
+ * @private
+ * @param {Number} maxTooltipHeight this the max height in pixels of the tooltip
+ */
+ _adjustTooltipHeight(maxTooltipHeight) {
+ const sizeOneLine = this.tooltip.querySelector("tbody tr").clientHeight;
+ const tbodySize = this.tooltip.querySelector("tbody").clientHeight;
+ const toKeep = Math.max(0, Math.floor(
+ (maxTooltipHeight - (this.tooltip.clientHeight - tbodySize)
+ ) / sizeOneLine) - 1);
+ const lines = this.tooltip.querySelectorAll("tbody tr");
+ const toRemove = lines.length - toKeep;
+ if (toRemove > 0) {
+ for (let index = toKeep; index < lines.length; ++index) {
+ lines[index].remove();
+ }
+ const tr = document.createElement("tr");
+ const td = document.createElement("td");
+ tr.classList.add("o_show_more");
+ td.innerHTML = this.env._t("...");
+ tr.appendChild(td);
+ this.tooltip.querySelector("tbody").appendChild(tr);
}
}
- return dataPoints;
- },
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
+ /**
+ * Creates a bar chart config.
+ * @private
+ */
+ _createBarChartConfig() {
+ // prepare data
+ const data = this._prepareData(this.processedDataPoints);
- /**
- * @private
- * @param {MouseEvent} ev
- */
- _onGraphClicked: function (ev) {
- const activeElement = this.chart.getElementAtEvent(ev);
- if (activeElement.length === 0) {
- return;
- }
- const domain = this.chart.data.datasets[activeElement[0]._datasetIndex].domain;
- if (!domain) {
- return; // empty dataset
- }
- this.trigger_up('open_view', {
- domain: domain[activeElement[0]._index],
- });
- },
- /**
- * If the text of a legend item has been shortened and the user mouse over
- * that item (actually the event type is mousemove), a tooltip with the item
- * full text is displayed.
- *
- * @private
- * @param {MouseEvent} e
- * @param {Object} legendItem
- */
- _onlegendTooltipHover: function (e, legendItem) {
- // set cursor pointer on hover of legend
- e.target.style.cursor = 'pointer';
- // The string legendItem.text is an initial segment of legendItem.fullText.
- // If the two coincide, no need to generate a tooltip.
- // If a tooltip for the legend already exists, it is already good and don't need
- // to be recreated.
- if (legendItem.text === legendItem.fullText || this.$legendTooltip) {
- return;
- }
-
- const chartAreaLeft = this.chart.chartArea.left;
- const chartAreaRight = this.chart.chartArea.right;
- const rendererTop = this.$el[0].getBoundingClientRect().top;
-
- this.$legendTooltip = $('', {
- class: "o_tooltip_legend",
- text: legendItem.fullText,
- css: {
- maxWidth: Math.floor((chartAreaRight - chartAreaLeft) / 1.68) + 'px',
- top: (e.clientY - rendererTop) + 'px',
+ for (let index = 0; index < data.datasets.length; ++index) {
+ const dataset = data.datasets[index];
+ // used when stacked
+ if (this.props.stacked) {
+ dataset.stack = this.props.origins[dataset.originIndex];
+ }
+ // set dataset color
+ dataset.backgroundColor = getColor(index);
}
- });
- const $container = this.$el.find('.o_graph_canvas_container');
- $container.append(this.$legendTooltip);
- this._fixTooltipLeftPosition(this.$legendTooltip[0], e.clientX);
- },
- /**
- * If there's a legend tooltip and the user mouse out of the corresponding
- * legend item, the tooltip is removed.
- *
- * @private
- */
- _onLegendTootipLeave: function (e) {
- // remove cursor style pointer on mouseleave from legend
- e.target.style.cursor = "";
- if (this.$legendTooltip) {
- this.$legendTooltip.remove();
- this.$legendTooltip = null;
+ // prepare options
+ const options = this._prepareOptions(data.datasets.length);
+
+ // create bar chart config
+ return { data, options, type: "bar" };
}
- },
-});
+
+ /**
+ * Returns the graph configuration object.
+ * @private
+ * @returns {Object}
+ */
+ _createConfig() {
+ if (this.noContentHelperData) {
+ return {};
+ }
+ if (this.props.comparisonFieldIndex === 0) {
+ this.dateClasses = this._getDateClasses(this.processedDataPoints);
+ }
+ switch (this.props.mode) {
+ case "bar": return this._createBarChartConfig();
+ case "line": return this._createLineChartConfig();
+ case "pie": return this._createPieChartConfig();
+ }
+ }
+
+ /**
+ * Creates a line chart config.
+ * @private
+ */
+ _createLineChartConfig() {
+ // prepare data
+ const data = this._prepareData(this.processedDataPoints);
+ for (let index = 0; index < data.datasets.length; ++index) {
+ const dataset = data.datasets[index];
+ if (
+ this.props.processedGroupBy.length <= 1 &&
+ this.props.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 = getColor(index);
+ }
+ } else {
+ dataset.borderColor = getColor(index);
+ }
+ if (data.labels.length === 1) {
+ // shift 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)";
+ }
+
+ if (data.datasets.length === 1) {
+ const dataset = data.datasets[0];
+ dataset.fill = "origin";
+ dataset.backgroundColor = hexToRGBA(COLORS[0], 0.4);
+ }
+
+ // center the points in the chart (without that code they are put
+ // on the left and the graph seems empty)
+ data.labels = data.labels.length > 1 ?
+ data.labels :
+ [[""], ...data.labels, [""]];
+
+ // prepare options
+ const options = this._prepareOptions(data.datasets.length);
+
+ // create line chart config
+ return { data, options, type: "line" };
+ }
+
+ /**
+ * Creates a pie chart config.
+ * @private
+ */
+ _createPieChartConfig() {
+ // prepare data
+ let data = {};
+ const allZero = this.processedDataPoints.every(
+ datapt => datapt.value === 0
+ );
+ if (allZero) {
+ // add fake data to display a pie chart with a grey zone associated
+ // with every origin
+ data.labels = [this.noDataLabel];
+ data.datasets = this.props.origins.map(origin => {
+ return {
+ label: origin,
+ data: [1],
+ backgroundColor: [DEFAULT_BG],
+ };
+ });
+ } else {
+ data = this._prepareData(this.processedDataPoints);
+ // give same color to same groups from different origins
+ const colors = data.labels.map((_, index) => getColor(index));
+ for (const dataset of data.datasets) {
+ dataset.backgroundColor = colors;
+ dataset.borderColor = "rgba(255,255,255,0.6)";
+ }
+ // make sure there is a zone associated with every origin
+ const representedOriginIndexes = data.datasets.map(
+ dataset => dataset.originIndex
+ );
+ let addNoDataToLegend = false;
+ const fakeData = new Array(data.labels.length).concat([1]);
+
+ for (let index = 0; index < this.props.origins.length; ++index) {
+ const origin = this.props.origins[index];
+ if (!representedOriginIndexes.includes(index)) {
+ data.datasets.splice(index, 0, {
+ label: origin,
+ data: fakeData,
+ backgroundColor: [...colors, DEFAULT_BG],
+ });
+ addNoDataToLegend = true;
+ }
+ }
+ if (addNoDataToLegend) {
+ data.labels.push(this.noDataLabel);
+ }
+ }
+
+ // prepare options
+ const options = this._prepareOptions(data.datasets.length);
+
+ // create pie chart config
+ return { data, options, type: "pie" };
+ }
+
+ /**
+ * Creates a custom HTML tooltip.
+ * @private
+ * @param {Object} tooltipModel see chartjs documentation
+ */
+ _customTooltip(tooltipModel) {
+ this.el.style.cursor = "";
+ this._removeTooltips();
+ if (tooltipModel.opacity === 0 || tooltipModel.dataPoints.length === 0) {
+ return;
+ }
+ if (this._isRedirectionEnabled()) {
+ this.el.style.cursor = "pointer";
+ }
+
+ const chartAreaTop = this.chart.chartArea.top;
+ const rendererTop = this.el.getBoundingClientRect().top;
+
+ const innerHTML = this.env.qweb.renderToString("web.GraphRenderer.CustomTooltip", {
+ maxWidth: getMaxWidth(this.chart.chartArea),
+ measure: this.props.fields[this.props.measure].string,
+ tooltipItems: this._getTooltipItems(tooltipModel),
+ });
+ const template = Object.assign(document.createElement("template"), { innerHTML });
+ this.tooltip = template.content.firstChild;
+
+ this.containerRef.el.prepend(this.tooltip);
+
+ let top;
+ const tooltipHeight = this.tooltip.clientHeight;
+ const minTopAllowed = Math.floor(chartAreaTop);
+ const maxTopAllowed = Math.floor(window.innerHeight - (rendererTop + tooltipHeight)) - 2;
+ const y = Math.floor(tooltipModel.y);
+
+ if (minTopAllowed <= maxTopAllowed) {
+ // Here we know that the full tooltip can fit in the screen.
+ // We put it in the position where Chart.js would put it
+ // if two conditions are respected:
+ // 1: the tooltip is not cut (because we know it is possible to not cut it)
+ // 2: the tooltip does not hide the legend.
+ // If it is not possible to use the Chart.js proposition (y)
+ // we use the best approximated value.
+ if (y <= maxTopAllowed) {
+ if (y >= minTopAllowed) {
+ top = y;
+ } else {
+ top = minTopAllowed;
+ }
+ } else {
+ top = maxTopAllowed;
+ }
+ } else {
+ // Here we know that we cannot satisfy condition 1 above,
+ // so we position the tooltip at the minimal position and
+ // cut it the minimum possible.
+ top = minTopAllowed;
+ const maxTooltipHeight = window.innerHeight - (rendererTop + chartAreaTop) - 2;
+ this._adjustTooltipHeight(maxTooltipHeight);
+ }
+
+ this._fixTooltipLeftPosition(this.tooltip, tooltipModel.x);
+ this.tooltip.style.top = Math.floor(top) + "px";
+ }
+
+ /**
+ * Filters 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.
+ * Note: this should be moved to the model at some point.
+ * @private
+ * @param {Object} props
+ * @returns {Object[]}
+ */
+ _filterDataPoints(props) {
+ let dataPoints = [];
+ if (props.mode === "line") {
+ let counts = 0;
+ for (const dataPoint of props.dataPoints) {
+ if (dataPoint.labels[0] !== this.env._t("Undefined")) {
+ dataPoints.push(dataPoint);
+ }
+ counts += dataPoint.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 = [];
+ }
+ } else {
+ dataPoints = props.dataPoints.filter(
+ dataPoint => dataPoint.count > 0
+ );
+ }
+ return dataPoints;
+ }
+
+ /**
+ * Sets best left position of a tooltip approaching the proposal x.
+ * @private
+ * @param {DOMElement} tooltip
+ * @param {number} x, left offset proposed
+ */
+ _fixTooltipLeftPosition(tooltip, x) {
+ let left;
+ const tooltipWidth = tooltip.clientWidth;
+ const minLeftAllowed = Math.floor(this.chart.chartArea.left + 2);
+ const maxLeftAllowed = Math.floor(this.chart.chartArea.right - tooltipWidth - 2);
+ x = Math.floor(x);
+ if (x <= maxLeftAllowed) {
+ if (x >= minLeftAllowed) {
+ left = x;
+ } else {
+ left = minLeftAllowed;
+ }
+ } else {
+ left = maxLeftAllowed;
+ }
+ tooltip.style.left = left + "px";
+ }
+
+ /**
+ * Used to format correctly the values in tooltips and yAxes.
+ * @private
+ * @param {number} value
+ * @returns {string} The value formatted using fieldUtils.format.float
+ */
+ _formatValue(value) {
+ const formatter = fieldUtils.format.float;
+ const measure = this.props.fields[this.props.measure];
+ const formatedValue = formatter(value, measure, FORMAT_OPTIONS);
+ return formatedValue;
+ }
+
+ /**
+ * Determines the initial section of the labels array over which
+ * a dataset has to be completed. The section only depends on the
+ * datasets origins.
+ * @private
+ * @param {number} originIndex
+ * @param {number} defaultLength
+ * @returns {number}
+ */
+ _getDatasetDataLength(originIndex, defaultLength) {
+ if (this.props.mode !== "pie" && this.props.comparisonFieldIndex === 0) {
+ return this.dateClasses.dateSets[originIndex].length;
+ }
+ return defaultLength;
+ }
+
+ /**
+ * Determines the dataset to which the data point belongs.
+ * @private
+ * @param {Object} dataPoint
+ * @returns {string}
+ */
+ _getDatasetLabel({ labels, originIndex }) {
+ if (this.props.mode === "pie") {
+ return this.props.origins[originIndex];
+ }
+ // ([origin] + second to last groupBys) or measure
+ let datasetLabel = labels.slice(1).join("/");
+ if (this.props.origins.length > 1) {
+ datasetLabel = this.props.origins[originIndex] + (
+ datasetLabel ? ("/" + datasetLabel) : ""
+ );
+ }
+ datasetLabel = datasetLabel || this.props.fields[this.props.measure].string;
+ return datasetLabel;
+ }
+
+ /**
+ * Returns a DateClasses instance used to manage equivalence of dates.
+ * @private
+ * @param {Object[]} dataPoints
+ * @returns {DateClasses}
+ */
+ _getDateClasses(dataPoints) {
+ const dateSets = this.props.origins.map(() => []);
+ for (const { labels, originIndex } of dataPoints) {
+ dateSets[originIndex].push(labels[this.props.comparisonFieldIndex]);
+ }
+ return new DateClasses(dateSets.map(dateSet => [...new Set(dateSet)]));
+ }
+
+ /**
+ * Returns an object used to style chart elements independently from
+ * the datasets.
+ * @private
+ * @returns {Object}
+ */
+ _getElementOptions() {
+ const elementOptions = {};
+ if (this.props.mode === "bar") {
+ elementOptions.rectangle = { borderWidth: 1 };
+ } else if (this.props.mode === "line") {
+ elementOptions.line = {
+ tension: 0,
+ fill: false,
+ };
+ }
+ return elementOptions;
+ }
+
+ /**
+ * Gets the label over which the data point is.
+ * @private
+ * @param {Object} dataPoint
+ * @returns {Array}
+ */
+ _getLabel({ labels, originIndex }) {
+ const index = this.props.comparisonFieldIndex;
+ if (this.props.mode !== "pie") {
+ if (index === 0) {
+ return [this.dateClasses.dateClass(originIndex, labels[index])];
+ } else {
+ return labels.slice(0, 1);
+ }
+ } else if (index === 0) {
+ return [
+ this.dateClasses.dateClass(originIndex, labels[index]),
+ ...labels.slice(index + 1)
+ ];
+ } else {
+ return labels;
+ }
+ }
+
+ /**
+ * Returns the options used to generate the chart legend.
+ * @private
+ * @param {number} datasetsCount
+ * @returns {Object}
+ */
+ _getLegendOptions(datasetsCount) {
+ const legendOptions = {
+ display: datasetsCount <= MAX_LEGEND_LENGTH,
+ position: "top",
+ onHover: this._onlegendHover.bind(this),
+ onLeave: this._onLegendLeave.bind(this),
+ };
+ if (this.props.mode === "line") {
+ legendOptions.onClick = this._onLegendClick.bind(this);
+ }
+ if (this.props.mode !== "pie") {
+ let referenceColor;
+ if (this.props.mode === "bar") {
+ referenceColor = "backgroundColor";
+ } else {
+ referenceColor = "borderColor";
+ }
+ legendOptions.labels = {
+ generateLabels: chart => {
+ const { data } = chart;
+ const labels = data.datasets.map((dataset, index) => {
+ return {
+ text: shortenLabel(dataset.label),
+ fullText: dataset.label,
+ fillStyle: dataset[referenceColor],
+ hidden: !chart.isDatasetVisible(index),
+ lineCap: dataset.borderCapStyle,
+ lineDash: dataset.borderDash,
+ lineDashOffset: dataset.borderDashOffset,
+ lineJoin: dataset.borderJoinStyle,
+ lineWidth: dataset.borderWidth,
+ strokeStyle: dataset[referenceColor],
+ pointStyle: dataset.pointStyle,
+ datasetIndex: index,
+ };
+ });
+ return labels;
+ },
+ };
+ } else {
+ const { comparisonFieldIndex } = this.props;
+ legendOptions.labels = {
+ generateLabels: chart => {
+ const { data } = chart;
+ const metaData = data.datasets.map(
+ (_, index) => chart.getDatasetMeta(index).data
+ );
+ const labels = data.labels.map((label, index) => {
+ const hidden = metaData.some(
+ data => data[index] && data[index].hidden
+ );
+ const fullText = this._relabelling(label, comparisonFieldIndex);
+ const text = shortenLabel(fullText);
+ const fillStyle = label === this.noDataLabel ?
+ DEFAULT_BG :
+ getColor(index);
+ return { text, fullText, fillStyle, hidden, index };
+ });
+ return labels;
+ },
+ };
+ }
+ return legendOptions;
+ }
+
+ /**
+ * Determines whether the data are good, and displays an error message
+ * if this is not the case.
+ * @private
+ * @returns {Object | null}
+ */
+ _getNoContentHelper() {
+ if (this.props.mode === "pie") {
+ const dataPoints = this.processedDataPoints;
+ const someNegative = dataPoints.some(dataPt => dataPt.value < 0);
+ const somePositive = dataPoints.some(dataPt => dataPt.value > 0);
+ if (someNegative && somePositive) {
+ return {
+ title: this.env._t("Invalid data"),
+ description: [
+ this.env._t("Pie chart cannot mix positive and negative numbers. "),
+ this.env._t("Try to change your domain to only display positive results")
+ ].join("")
+ };
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the options used to generate the chart axes.
+ * @private
+ * @returns {Object}
+ */
+ _getScaleOptions() {
+ if (this.props.mode === "pie") {
+ return {};
+ }
+ const { comparisonFieldIndex } = this.props;
+ const xAxes = [{
+ type: "category",
+ scaleLabel: {
+ display: this.props.processedGroupBy.length && !this.props.isEmbedded,
+ labelString: this.props.processedGroupBy.length ?
+ this.props.fields[this.props.processedGroupBy[0].split(":")[0]].string :
+ "",
+ },
+ ticks: { callback: label => this._relabelling(label, comparisonFieldIndex) },
+ }];
+ const yAxes = [{
+ type: "linear",
+ scaleLabel: {
+ display: !this.props.isEmbedded,
+ labelString: this.props.fields[this.props.measure].string,
+ },
+ ticks: {
+ callback: value => this._formatValue(value),
+ suggestedMax: 0,
+ suggestedMin: 0,
+ },
+ }];
+ return { xAxes, yAxes };
+ }
+
+ /**
+ * Extracts the important information from a tooltipItem generated by
+ * Charts.js (a tooltip item corresponds to a line (different from
+ * measure name) of a tooltip).
+ * @private
+ * @param {Object} item
+ * @param {Object} data
+ * @returns {Object}
+ */
+ _getTooltipItemContent(item, data) {
+ const { comparisonFieldIndex } = this.props;
+ const dataset = data.datasets[item.datasetIndex];
+ const id = item.index;
+ let label = data.labels[item.index];
+ let value;
+ let boxColor;
+ if (this.props.mode === "pie") {
+ if (label === this.noDataLabel) {
+ value = this._formatValue(0);
+ } else {
+ value = this._formatValue(dataset.data[item.index]);
+ }
+ label = this._relabelling(label, comparisonFieldIndex, dataset.originIndex);
+ if (this.props.origins.length > 1) {
+ label = `${dataset.label}/${label}`;
+ }
+ boxColor = dataset.backgroundColor[item.index];
+ } else {
+ label = this._relabelling(label, comparisonFieldIndex, dataset.originIndex);
+ if (
+ this.props.processedGroupBy.length > 1 ||
+ this.props.origins.length > 1
+ ) {
+ label = `${label}/${dataset.label}`;
+ }
+ value = this._formatValue(item.yLabel);
+ boxColor = this.props.mode === "bar" ?
+ dataset.backgroundColor :
+ dataset.borderColor;
+ }
+ return { id, label, value, boxColor };
+ }
+
+ /**
+ * This function extracts the information from the data points in
+ * tooltipModel.dataPoints (corresponding to datapoints over a given
+ * label determined by the mouse position) that will be displayed in a
+ * custom tooltip.
+ * @private
+ * @param {Object} tooltipModel see chartjs documentation
+ * @return {Object[]}
+ */
+ _getTooltipItems(tooltipModel) {
+ const { data } = this.chart.config;
+ const sortedDataPoints = sortBy(tooltipModel.dataPoints, "yLabel", "desc");
+ return sortedDataPoints.map(
+ item => this._getTooltipItemContent(item, data)
+ );
+ }
+
+ /**
+ * Returns the options used to generate chart tooltips.
+ * @private
+ * @returns {Object}
+ */
+ _getTooltipOptions() {
+ const tooltipOptions = {
+ // disable Chart.js tooltips
+ enabled: false,
+ custom: this._customTooltip.bind(this),
+ };
+ if (this.props.mode === "line") {
+ tooltipOptions.mode = "index";
+ tooltipOptions.intersect = false;
+ }
+ return tooltipOptions;
+ }
+
+ /**
+ * Returns true iff the current graph can be clicked on to redirect to
+ * the list of records.
+ * @private
+ * @returns {boolean}
+ */
+ _isRedirectionEnabled() {
+ return !this.props.disableLinking && this.props.mode !== "line";
+ }
+
+ /**
+ * Separates dataPoints coming from the read_group(s) into different
+ * datasets. This function returns the parameters data and labels used
+ * to produce the charts.
+ * @param {Object[]} dataPoints
+ * @returns {Object}
+ */
+ _prepareData(dataPoints) {
+ const labelMap = {};
+ const labels = [];
+ for (const dataPt of dataPoints) {
+ const label = this._getLabel(dataPt);
+ const labelKey = `${dataPt.resId}:${JSON.stringify(label)}`;
+ const index = labelMap[labelKey];
+ if (index === undefined) {
+ labelMap[labelKey] = dataPt.labelIndex = labels.length;
+ labels.push(label);
+ } else {
+ dataPt.labelIndex = index;
+ }
+ }
+
+ // dataPoints --> datasets
+ const datasetsTmp = {};
+ for (const dp of dataPoints) {
+ const datasetLabel = this._getDatasetLabel(dp);
+ if (!(datasetLabel in datasetsTmp)) {
+ const dataLength = this._getDatasetDataLength(dp.originIndex, labels.length);
+ datasetsTmp[datasetLabel] = {
+ data: new Array(dataLength).fill(0),
+ domain: new Array(dataLength).fill([]),
+ label: datasetLabel,
+ originIndex: dp.originIndex,
+ };
+ }
+ const labelIndex = dp.labelIndex;
+ datasetsTmp[datasetLabel].data[labelIndex] = dp.value;
+ datasetsTmp[datasetLabel].domain[labelIndex] = dp.domain;
+ }
+ // sort by origin
+ const datasets = sortBy(Object.values(datasetsTmp), "originIndex");
+ return { datasets, labels };
+ }
+
+ /**
+ * Prepares 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(datasetsCount) {
+ const options = {
+ maintainAspectRatio: false,
+ scales: this._getScaleOptions(),
+ legend: this._getLegendOptions(datasetsCount),
+ tooltips: this._getTooltipOptions(),
+ elements: this._getElementOptions(),
+ };
+ if (this._isRedirectionEnabled()) {
+ options.onClick = ev => this._onGraphClicked(ev);
+ }
+ return options;
+ }
+
+ /**
+ * Computes various information from the given props object.
+ * @param {Object} props
+ */
+ _processProps(props) {
+ const filteredDataPoints = this._filterDataPoints(props);
+ this.processedDataPoints = this._sortDataPoints(filteredDataPoints, props);
+ this.noContentHelperData = this._getNoContentHelper(props.mode);
+ }
+
+ /**
+ * Determines 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 coming from a same
+ * origin.
+ * @private
+ * @param {Array} label
+ * @param {number} index
+ * @param {Array} [originIndex]
+ * @returns {string}
+ */
+ _relabelling(label, index, originIndex) {
+ if (label === this.noDataLabel) {
+ return label[0];
+ }
+ if (this.props.mode !== "pie" && index === 0) {
+ // here label is an array of length 1 and contains a number
+ return this.dateClasses.representative(label, originIndex) || "";
+ } else if (this.props.mode === "pie" && index === 0) {
+ // here label is an array of length at least one containing string or numbers
+ const labelCopy = label.slice();
+ let newLabel;
+ if (originIndex === undefined) {
+ newLabel = this.dateClasses.dateClassMembers(label[index]);
+ } else {
+ newLabel = this.dateClasses.representative(label[index], originIndex);
+ }
+ labelCopy.splice(index, 1, newLabel);
+ return labelCopy.join("/");
+ }
+ // here label is an array containing strings or numbers.
+ return label.join("/") || this.env._t("Total");
+ }
+
+ /**
+ * Removes all existing tooltips.
+ * @private
+ */
+ _removeTooltips() {
+ if (this.tooltip) {
+ this.tooltip.remove();
+ this.tooltip = null;
+ }
+ if (this.legendTooltip) {
+ this.legendTooltip.remove();
+ this.legendTooltip = null;
+ }
+ }
+
+ /**
+ * Instantiates a Chart (Chart.js lib) to render the graph according to
+ * the current config.
+ * @private
+ */
+ _renderChart() {
+ if (this.noContentHelperData) {
+ return;
+ }
+ if (this.chart) {
+ this.chart.destroy();
+ }
+ const config = this._createConfig();
+ const canvasContext = this.canvasRef.el.getContext("2d");
+ this.chart = new Chart(canvasContext, config);
+ // To perform its animations, ChartJS will perform each animation
+ // step in the next animation frame. The initial rendering itself
+ // is delayed for consistency. We can avoid this by manually
+ // advancing the animation service.
+ Chart.animationService.advance();
+ }
+
+ /**
+ * Sorts datapoints according to the current order (ASC or DESC).
+ * Note: this should be moved to the model at some point.
+ * @private
+ * @param {Object[]} dataPoints
+ * @param {Object} props
+ * @returns {Object[]} sorted dataPoints if orderby set on state
+ */
+ _sortDataPoints(dataPoints, props) {
+ if (
+ props.domains.length === 1 &&
+ props.orderBy &&
+ props.mode !== "pie" &&
+ props.processedGroupBy.length
+ ) {
+ // group data by their x-axis value, and then sort datapoints
+ // based on the sum of values by group in ascending/descending order
+ const [groupByFieldName] = props.processedGroupBy[0].split(":");
+ const { type } = props.fields[groupByFieldName];
+ const groupedDataPoints = {};
+ for (const dataPt of dataPoints) {
+ const key = type === "many2one" ? dataPt.resId : dataPt.labels[0];
+ if (!groupedDataPoints[key]) {
+ groupedDataPoints[key] = [];
+ }
+ groupedDataPoints[key].push(dataPt);
+ }
+ const groupTotal = group => group.reduce((sum, { value }) => sum + value, 0);
+ dataPoints = sortBy(
+ Object.values(groupedDataPoints),
+ groupTotal,
+ props.orderBy
+ ).flat();
+ }
+ return dataPoints;
+ }
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onGraphClicked(ev) {
+ const [activeElement] = this.chart.getElementAtEvent(ev);
+ if (!activeElement) {
+ return;
+ }
+ const { _datasetIndex, _index } = activeElement;
+ const { domain } = this.chart.data.datasets[_datasetIndex];
+ if (domain) {
+ this.trigger("open_view", { domain: domain[_index] });
+ }
+ }
+
+ /**
+ * Overrides the default legend 'onClick' behaviour. This is done to
+ * remove all existing tooltips right before updating the chart.
+ * @private
+ * @param {MouseEvent} ev
+ * @param {Object} legendItem
+ */
+ _onLegendClick(ev, legendItem) {
+ this._removeTooltips();
+ // Default 'onClick' fallback. See web/static/lib/Chart/Chart.js#15138
+ const index = legendItem.datasetIndex;
+ const meta = this.chart.getDatasetMeta(index);
+ meta.hidden = meta.hidden === null ? !this.chart.data.datasets[index].hidden : null;
+ this.chart.update();
+ }
+
+ /**
+ * If the text of a legend item has been shortened and the user mouse
+ * hovers that item (actually the event type is mousemove), a tooltip
+ * with the item full text is displayed.
+ * @private
+ * @param {MouseEvent} ev
+ * @param {Object} legendItem
+ */
+ _onlegendHover(ev, legendItem) {
+ this.canvasRef.el.style.cursor = "pointer";
+ /**
+ * The string legendItem.text is an initial segment of legendItem.fullText.
+ * If the two coincide, no need to generate a tooltip. If a tooltip
+ * for the legend already exists, it is already good and doesn't
+ * need to be recreated.
+ */
+ if (legendItem.text === legendItem.fullText || this.legendTooltip) {
+ return;
+ }
+
+ const rendererTop = this.el.getBoundingClientRect().top;
+
+ this.legendTooltip = Object.assign(document.createElement("div"), {
+ className: "o_tooltip_legend",
+ innerText: legendItem.fullText,
+ });
+ this.legendTooltip.style.top = (ev.clientY - rendererTop) + "px";
+ this.legendTooltip.style.maxWidth = getMaxWidth(this.chart.chartArea);
+
+ this.containerRef.el.appendChild(this.legendTooltip);
+
+ this._fixTooltipLeftPosition(this.legendTooltip, ev.clientX);
+ }
+
+ /**
+ * If there's a legend tooltip and the user mouse out of the
+ * corresponding legend item, the tooltip is removed.
+ * @private
+ */
+ _onLegendLeave() {
+ this.canvasRef.el.style.cursor = "";
+ if (this.legendTooltip) {
+ this.legendTooltip.remove();
+ this.legendTooltip = null;
+ }
+ }
+ }
+
+ GraphRenderer.template = "web.GraphRenderer";
+ GraphRenderer.props = {
+ arch: {
+ type: Object,
+ shape: {
+ children: { type: Array, element: Object },
+ attrs: Object,
+ tag: { validate: t => t === "graph" },
+ },
+ },
+ comparisonFieldIndex: Number,
+ context: Object,
+ dataPoints: { type: Array, element: Object },
+ disableLinking: Boolean,
+ domain: [Array, String],
+ domains: { type: Array, element: [Array, String] },
+ fields: Object,
+ groupBy: { type: Array, element: String },
+ isEmbedded: Boolean,
+ isSample: { type: Boolean, optional: 1 },
+ measure: String,
+ mode: { validate: m => ["bar", "line", "pie"].includes(m) },
+ origins: { type: Array, element: String },
+ processedGroupBy: { type: Array, element: String },
+ stacked: Boolean,
+ timeRanges: Object,
+ noContentHelp: { type: String, optional: 1 },
+ orderBy: { type: [String, Boolean], optional: 1 },
+ title: { type: String, optional: 1 },
+ withSearchPanel: { type: Boolean, optional: 1 },
+ };
+
+ return patchMixin(GraphRenderer);
+
});
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 759817c898f..69344186422 100644
--- a/addons/web/static/src/js/views/graph/graph_view.js
+++ b/addons/web/static/src/js/views/graph/graph_view.js
@@ -11,7 +11,8 @@ var AbstractView = require('web.AbstractView');
var core = require('web.core');
var GraphModel = require('web.GraphModel');
var Controller = require('web.GraphController');
-var GraphRenderer = require('web.GraphRenderer');
+const GraphRenderer = require("web/static/src/js/views/graph/graph_renderer");
+const RendererWrapper = require("web.RendererWrapper");
var _t = core._t;
var _lt = core._lt;
@@ -155,6 +156,15 @@ var GraphView = AbstractView.extend({
this.loadParams.comparisonDomain = params.comparisonDomain;
this.loadParams.stacked = this.arch.attrs.stacked !== "False";
},
+
+ /**
+ *
+ * @override
+ */
+ getRenderer(parent, props) {
+ props = Object.assign(props || {}, this.rendererParams);
+ return new RendererWrapper(null, this.config.Renderer, props);
+ },
});
return GraphView;
diff --git a/addons/web/static/src/scss/graph_view.scss b/addons/web/static/src/scss/graph_view.scss
index 45e733ec60c..ddf841c4989 100644
--- a/addons/web/static/src/scss/graph_view.scss
+++ b/addons/web/static/src/scss/graph_view.scss
@@ -1,16 +1,14 @@
-.o_graph_controller {
- .o_graph_renderer {
+.o_graph_renderer {
+ height: 100%;
+ .o_graph_canvas_container {
+ padding-top: 5px;
+ position: relative;
height: 100%;
- .o_graph_canvas_container {
- padding-top: 5px;
- position: relative;
- height: 100%;
- width: 100%;
- top: 0px;
- left: 0px;
- canvas {
- background-color: $o-view-background-color;
- }
+ width: 100%;
+ top: 0px;
+ left: 0px;
+ canvas {
+ background-color: $o-view-background-color;
}
}
@@ -56,6 +54,7 @@
width: 12px;
display: inline-block;
vertical-align: middle;
+ margin-right: 5px;
}
span.o_label {
text-overflow: ellipsis;
diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml
index 4a86def08bf..8143237c08d 100644
--- a/addons/web/static/src/xml/base.xml
+++ b/addons/web/static/src/xml/base.xml
@@ -987,27 +987,6 @@
-
-