[MERGE] web: Various graph view improvements
This PR aims to bring several improvements to the graph view. The main points are: - custom tooltips in line mode to avoid problems with the Chart.js generic tooltips (those cannot go outside of the canvas boundary and information is lost). - tooltips when legend items hovered in case their texts were cut for reason of space. See task https://www.odoo.com/web#id=1963306&action=333&active_id=133&model=project.task&view_type=form&menu_id=4720 closes odoo/odoo#33910 Signed-off-by: VincentSchippefilt <VincentSchippefilt@users.noreply.github.com>
This commit is contained in:
Vendored
+11532
-11308
File diff suppressed because it is too large
Load Diff
@@ -172,7 +172,7 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{
|
||||
.find('.o_graph_button[data-mode="stack"]')
|
||||
.data('stacked', state.stacked)
|
||||
.toggleClass('active', state.stacked)
|
||||
.toggleClass('o_hidden', state.mode !== 'bar');
|
||||
.toggleClass('o_hidden', state.mode !== 'bar' || !this.renderer.isStackable);
|
||||
_.each(this.$measureList.find('.dropdown-item'), function (item) {
|
||||
var $item = $(item);
|
||||
$item.toggleClass('selected', $item.data('field') === state.measure);
|
||||
|
||||
@@ -219,7 +219,7 @@ return AbstractModel.extend({
|
||||
/**
|
||||
* Since read_group is insane and returns its result on different keys
|
||||
* depending of some input, we have to normalize the result.
|
||||
* Each group comming from the read_group produces a dataPoint
|
||||
* Each group coming from the read_group produces a dataPoint
|
||||
*
|
||||
* @todo This is not good for race conditions. The processing should get
|
||||
* the object this.chart in argument, or an array or something. We want to
|
||||
@@ -247,7 +247,7 @@ return AbstractModel.extend({
|
||||
// when a many2one field is used as a measure AND as a grouped
|
||||
// field, bad things happen. The server will only return the
|
||||
// grouped value and will not aggregate it. Since there is a
|
||||
// nameclash, we are then in the situation where this value is
|
||||
// name clash, we are then in the situation where this value is
|
||||
// an array. Fortunately, if we group by a field, then we can
|
||||
// say for certain that the group contains exactly one distinct
|
||||
// value for that field.
|
||||
|
||||
@@ -18,15 +18,16 @@ 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 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) {
|
||||
|
||||
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 parseInt(n, 16);
|
||||
}).join(',');
|
||||
return 'rgba(' + rgb + ',' + opacity + ')';
|
||||
}
|
||||
|
||||
@@ -45,13 +46,11 @@ var FORMAT_OPTIONS = {
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
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));
|
||||
var MAX_TOOLTIPS_LENGTH = 4 * (Math.max(1, config.device.size_class));
|
||||
|
||||
return AbstractRenderer.extend({
|
||||
className: "o_graph_renderer",
|
||||
@@ -72,22 +71,21 @@ return AbstractRenderer.extend({
|
||||
|
||||
this.chart = null;
|
||||
this.chartId = _.uniqueId('chart');
|
||||
// the bar charts are considered stackable a priori
|
||||
this.isStackable = true;
|
||||
this.$legendTooltip = null;
|
||||
this.$tooltip = null;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
updateState: function () {
|
||||
|
||||
updateState: function () {
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
* here use the on_attach_callback hook, called when the widget is attached
|
||||
* to the DOM, to perform the rendering. This ensures that the rendering is
|
||||
* always done in the DOM.
|
||||
* 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
|
||||
*/
|
||||
@@ -108,6 +106,98 @@ return AbstractRenderer.extend({
|
||||
// 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: 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) {
|
||||
if (this.$tooltip) {
|
||||
this.$tooltip.remove();
|
||||
}
|
||||
if (tooltipModel.opacity === 0) {
|
||||
return;
|
||||
}
|
||||
if (tooltipModel.dataPoints.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
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.
|
||||
@@ -141,20 +231,28 @@ return AbstractRenderer.extend({
|
||||
return dataPoints;
|
||||
},
|
||||
/**
|
||||
* Used to avoid too long legend items
|
||||
* Sets best left position of a tooltip approaching the proposal x
|
||||
*
|
||||
* @private
|
||||
* @param {string} label
|
||||
* @returns {string} shortened version of the input label
|
||||
* @param {DOMElement} tooltip
|
||||
* @param {number} x, left offset proposed
|
||||
*/
|
||||
_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 + '/...';
|
||||
_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;
|
||||
}
|
||||
return shortLabel;
|
||||
tooltip.style.left = left + 'px';
|
||||
},
|
||||
/**
|
||||
* Used to format correctly the values in tooltips and yAxes
|
||||
@@ -202,13 +300,13 @@ return AbstractRenderer.extend({
|
||||
* @param {Object} dataPt
|
||||
* @returns {string}
|
||||
*/
|
||||
_getDatasetLabel:function (dataPt) {
|
||||
_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) : '');
|
||||
}
|
||||
datasetLabel = datasetLabel || this.fields[this.state.measure].string;
|
||||
return datasetLabel;
|
||||
@@ -264,13 +362,16 @@ return AbstractRenderer.extend({
|
||||
* Returns the options used to generate the chart legend.
|
||||
*
|
||||
* @private
|
||||
* @param {number} datasetsCount
|
||||
* @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',
|
||||
// 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)) {
|
||||
@@ -281,11 +382,12 @@ return AbstractRenderer.extend({
|
||||
referenceColor = 'borderColor';
|
||||
}
|
||||
legendOptions.labels = {
|
||||
generateLabels: function(chart) {
|
||||
generateLabels: function (chart) {
|
||||
var data = chart.data;
|
||||
return data.datasets.map(function(dataset, i) {
|
||||
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,
|
||||
@@ -302,12 +404,12 @@ return AbstractRenderer.extend({
|
||||
};
|
||||
} else {
|
||||
legendOptions.labels = {
|
||||
generateLabels: function(chart) {
|
||||
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) {
|
||||
return data.labels.map(function (label, i) {
|
||||
var hidden = metaData.reduce(
|
||||
function (hidden, data) {
|
||||
if (data[i]) {
|
||||
@@ -317,9 +419,11 @@ return AbstractRenderer.extend({
|
||||
},
|
||||
false
|
||||
);
|
||||
var text = self._shortenLabel(self._relabelling(label));
|
||||
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,
|
||||
@@ -345,7 +449,7 @@ return AbstractRenderer.extend({
|
||||
scaleLabel: {
|
||||
display: this.state.groupBy.length && !this.isEmbedded,
|
||||
labelString: this.state.groupBy.length ?
|
||||
this.fields[this.state.groupBy[0].split(':')[0]].string : '',
|
||||
this.fields[this.state.groupBy[0].split(':')[0]].string : '',
|
||||
},
|
||||
ticks: {
|
||||
// don't use bind: callback is called with 'index' as second parameter
|
||||
@@ -361,9 +465,9 @@ return AbstractRenderer.extend({
|
||||
display: !this.isEmbedded,
|
||||
labelString: this.fields[this.state.measure].string,
|
||||
},
|
||||
stacked: this.state.mode === 'bar' && this.state.stacked,
|
||||
ticks: {
|
||||
callback: this._formatValue.bind(this),
|
||||
suggestedMax: 0,
|
||||
suggestedMin: 0,
|
||||
}
|
||||
}],
|
||||
@@ -371,91 +475,91 @@ return AbstractRenderer.extend({
|
||||
}
|
||||
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.groupBy.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.groupBy.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;
|
||||
|
||||
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
|
||||
* @param {number} datasetsCount
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getTooltipOptions: function (datasetsCount) {
|
||||
var self = this;
|
||||
_getTooltipOptions: function () {
|
||||
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;
|
||||
},
|
||||
},
|
||||
// disable Chart.js tooltips
|
||||
enabled: false,
|
||||
custom: this._customTooltip.bind(this),
|
||||
};
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
if (this.state.mode === 'line') {
|
||||
tooltipOptions.mode = 'index';
|
||||
tooltipOptions.intersect = false;
|
||||
}
|
||||
return tooltipOptions;
|
||||
},
|
||||
@@ -488,7 +592,7 @@ return AbstractRenderer.extend({
|
||||
return index;
|
||||
},
|
||||
/**
|
||||
* Separate dataPoints comming from the read_group(s) into different datasets.
|
||||
* 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
|
||||
@@ -498,7 +602,7 @@ return AbstractRenderer.extend({
|
||||
* @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.
|
||||
* @returns {Object} the parameter data used to instantiate the chart.
|
||||
*/
|
||||
_prepareData: function (dataPoints) {
|
||||
var self = this;
|
||||
@@ -562,13 +666,13 @@ return AbstractRenderer.extend({
|
||||
maintainAspectRatio: false,
|
||||
scales: this._getScaleOptions(),
|
||||
legend: this._getLegendOptions(datasetsCount),
|
||||
tooltips: this._getTooltipOptions(datasetsCount),
|
||||
tooltips: this._getTooltipOptions(),
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 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.
|
||||
* it is when restricted to the set of dataPoints coming from a same origin.
|
||||
|
||||
* @private
|
||||
* @param {Array} label
|
||||
@@ -662,9 +766,35 @@ return AbstractRenderer.extend({
|
||||
|
||||
// prepare data
|
||||
var data = this._prepareData(dataPoints);
|
||||
|
||||
// The datapoints (with non zero value) can be grouped by
|
||||
// their originIndex and associated labels.
|
||||
// If at least one group formed in that way has two elements
|
||||
// this.isStackable will be true.
|
||||
var groupMemberCounts = data.labels.map(function () {
|
||||
return {};
|
||||
});
|
||||
this.isStackable = false;
|
||||
for (var i = 0; i < data.datasets.length; i++) {
|
||||
var dataset = data.datasets[i];
|
||||
var originIndex = dataset.originIndex;
|
||||
if (this.isStackable) {
|
||||
break;
|
||||
}
|
||||
for (var j = 0; j < dataset.data.length; j++) {
|
||||
if (dataset.data[j]) {
|
||||
if (originIndex in groupMemberCounts[j]) {
|
||||
this.isStackable = true;
|
||||
break;
|
||||
} else {
|
||||
groupMemberCounts[j][originIndex] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
data.datasets.forEach(function (dataset, index) {
|
||||
// used when stacked
|
||||
dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined;
|
||||
dataset.stack = self.isStackable && self.state.stacked ? self.state.origins[dataset.originIndex] : undefined;
|
||||
// set dataset color
|
||||
var color = self._getColor(index);
|
||||
dataset.backgroundColor = color;
|
||||
@@ -711,17 +841,23 @@ return AbstractRenderer.extend({
|
||||
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
|
||||
// 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)';
|
||||
});
|
||||
// center the points in the chart (whithout that code they are put on the left and the graph seems empty)
|
||||
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 ,[['']]]);
|
||||
data.labels :
|
||||
Array.prototype.concat.apply([], [[['']], data.labels, [['']]]);
|
||||
|
||||
// prepare options
|
||||
var options = this._prepareOptions(data.datasets.length);
|
||||
@@ -834,10 +970,80 @@ return AbstractRenderer.extend({
|
||||
*/
|
||||
_renderTitle: function () {
|
||||
if (this.title) {
|
||||
this.$('.o_graph_canvas_container').last().prepend($('<label/>', {
|
||||
this.$el.prepend($('<label/>', {
|
||||
text: this.title,
|
||||
}));
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 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 (shortLabel.length > 30) {
|
||||
shortLabel = shortLabel.slice(0, 30) + '...';
|
||||
} else if (groups.length > 3) {
|
||||
shortLabel = shortLabel + '/...';
|
||||
}
|
||||
return shortLabel;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// 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 = $('<div>', {
|
||||
class: "o_tooltip_legend",
|
||||
text: legendItem.fullText,
|
||||
css: {
|
||||
maxWidth: Math.floor((chartAreaRight - chartAreaLeft) / 1.68) + 'px',
|
||||
top: (e.clientY - rendererTop) + 'px',
|
||||
}
|
||||
});
|
||||
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 () {
|
||||
if (this.$legendTooltip) {
|
||||
this.$legendTooltip.remove();
|
||||
this.$legendTooltip = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
odoo.define('web.OldDropdownMenu', function (require) {
|
||||
"use strict";
|
||||
|
||||
var core = require('web.core');
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
|
||||
|
||||
|
||||
var OldDropdownMenu = Widget.extend({
|
||||
template: 'web.OldDropdownMenu',
|
||||
events: {
|
||||
'click .o_menu_item': '_onItemClick',
|
||||
'click .o_item_option': '_onOptionClick',
|
||||
'click span.o_trash_button': '_onTrashClick',
|
||||
'hidden.bs.dropdown': '_onBootstrapClose',
|
||||
},
|
||||
|
||||
/**
|
||||
* override
|
||||
*
|
||||
* @param {Widget} parent
|
||||
* @param {Object} dropdownHeader object used to customize the dropdown menu.
|
||||
* @param {String} dropdownHeader.title
|
||||
* @param {String} dropdownHeader.icon
|
||||
* @param {String} dropdownHeader.symbol
|
||||
* @param {String} dropdownHeader.category descripbes the type of items
|
||||
* @param {String} dropdownHeader.style the button style
|
||||
* @param {Object[]} items list of menu items
|
||||
*
|
||||
* Menu items:
|
||||
*
|
||||
* * itemId: string; (optional) unique id associated with the item
|
||||
* * description: string; label printed on screen
|
||||
* * groupId: string;
|
||||
* * isActive: boolean; (optional) determines if the item is considered active
|
||||
* * isOpen: boolean; (optional) in case there are options the submenu presenting the options is opened or closed according to isOpen
|
||||
* * isRemovable: boolean; (optional) can be removed from menu options: array of objects with 'optionId' and 'description' keys; (optional)
|
||||
* * currentOptionId: string refers to an optionId that is activated if item is active (optional)
|
||||
*/
|
||||
init: function (parent, dropdownHeader, items) {
|
||||
this._super(parent);
|
||||
this.dropdownCategory = dropdownHeader.category;
|
||||
this.dropdownTitle = dropdownHeader.title;
|
||||
this.dropdownIcon = dropdownHeader.icon;
|
||||
this.dropdownSymbol = dropdownHeader.symbol || false;
|
||||
// this parameter fixes the menu style. By default,
|
||||
// the style used is the one used in the search view
|
||||
this.dropdownStyle = dropdownHeader.style || {
|
||||
el: {class: 'btn-group o_dropdown', attrs: {}},
|
||||
mainButton: {class: 'o_dropdown_toggler_btn btn btn-secondary dropdown-toggle' + (this.dropdownSymbol ? ' o-no-caret' : '')},
|
||||
};
|
||||
this.items = items;
|
||||
_.each(this.items, this._prepareItem.bind(this));
|
||||
},
|
||||
/**
|
||||
* override
|
||||
*/
|
||||
start: function () {
|
||||
this.$menu = this.$('.o_dropdown_menu');
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {string[]} activeItemIds
|
||||
*/
|
||||
updateItemsStatus: function (activeItemIds) {
|
||||
_.each(this.items, function (item) {
|
||||
if (!_.contains(activeItemIds, item.itemId)) {
|
||||
item.isActive = false;
|
||||
item.currentOptionId = false;
|
||||
} else {
|
||||
item.isActive = true;
|
||||
item.currentOptionId = item.hasOptions ?
|
||||
(item.currentOptionId || item.defaultOptionId) :
|
||||
false;
|
||||
}
|
||||
});
|
||||
this._renderMenuItems();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} itemId
|
||||
*/
|
||||
_removeItem: function (itemId) {
|
||||
var item, itemIndex;
|
||||
item = _.findWhere(this.items, {itemId: itemId});
|
||||
itemIndex = this.items.indexOf(item);
|
||||
this.items.splice(itemIndex, 1);
|
||||
this._renderMenuItems();
|
||||
var eventData = {
|
||||
category: this.dropdownCategory,
|
||||
itemId: itemId,
|
||||
groupId: item.groupId,
|
||||
};
|
||||
this.trigger_up('menu_item_deleted', eventData);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_renderMenuItems: function () {
|
||||
var newMenuItems = QWeb.render('OldDropdownMenu.MenuItems', {widget: this});
|
||||
this.$el.find('.o_menu_item, .dropdown-divider[data-removable="1"]').remove();
|
||||
this.$('.o_dropdown_menu').prepend($(newMenuItems));
|
||||
},
|
||||
/**
|
||||
* this function computes some default values for some of the item keys
|
||||
* according to values passed at initialization
|
||||
*
|
||||
* @private
|
||||
* @param {Object} item
|
||||
*/
|
||||
_prepareItem: function (item) {
|
||||
item.itemId = item.itemId || _.uniqueId('__item__');
|
||||
item.isOpen = item.isOpen || false;
|
||||
item.isRemovable = item.isRemovable || false;
|
||||
if (item.options && item.options.length !== 0) {
|
||||
item.options = _.sortBy(item.options, 'groupId');
|
||||
item.hasOptions = true;
|
||||
item.defaultOptionId = item.defaultOptionId || this.defaultOptionId;
|
||||
item.currentOptionId = item.isActive && item.defaultOptionId ? item.defaultOptionId : false;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} itemId
|
||||
*/
|
||||
_toggleMenuItem: function (itemId) {
|
||||
var item = _.findWhere(this.items, {itemId: itemId});
|
||||
if (!item.hasOptions) {
|
||||
item.isActive = !item.isActive;
|
||||
var eventData = {
|
||||
category: this.dropdownCategory,
|
||||
itemId: itemId,
|
||||
isActive: item.isActive,
|
||||
groupId: item.groupId,
|
||||
};
|
||||
this._renderMenuItems();
|
||||
this.trigger_up('menu_item_toggled', eventData);
|
||||
}
|
||||
if (item.hasOptions) {
|
||||
item.isOpen = !item.isOpen;
|
||||
this._renderMenuItems();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} itemId
|
||||
* @param {string} optionId
|
||||
*/
|
||||
_toggleItemOption: function (itemId, optionId) {
|
||||
var item = _.findWhere(this.items, {itemId: itemId});
|
||||
var initialState = item.isActive;
|
||||
if (item.currentOptionId === optionId) {
|
||||
item.isActive = false;
|
||||
item.currentOptionId = false;
|
||||
} else {
|
||||
item.isActive = true;
|
||||
item.currentOptionId = optionId;
|
||||
}
|
||||
var eventData = {
|
||||
category: this.dropdownCategory,
|
||||
itemId: itemId,
|
||||
groupId: item.groupId,
|
||||
isActive: item.isActive,
|
||||
optionId: item.currentOptionId,
|
||||
};
|
||||
this._renderMenuItems();
|
||||
if (item.isActive !== initialState) {
|
||||
this.trigger_up('menu_item_toggled', eventData);
|
||||
} else {
|
||||
this.trigger_up('item_option_changed', eventData);
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* This method is called when Bootstrap trigger a 'close' event (for example
|
||||
* when the user clicks outside the dropdown menu).
|
||||
*
|
||||
* @private
|
||||
* @param {jQueryEvent} event
|
||||
*/
|
||||
_onBootstrapClose: function () {
|
||||
_.each(this.items, function (item) {
|
||||
item.isOpen = false;
|
||||
});
|
||||
this._renderMenuItems();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onItemClick: function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
var itemId = $(event.currentTarget).data('id');
|
||||
this._toggleMenuItem(itemId);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onOptionClick: function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
var optionId = $(event.currentTarget).data('option_id');
|
||||
var itemId = $(event.currentTarget).data('item_id');
|
||||
this._toggleItemOption(itemId, optionId);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onTrashClick: function (event) {
|
||||
event.stopPropagation();
|
||||
var itemId = $(event.currentTarget).data('id');
|
||||
this._removeItem(itemId);
|
||||
},
|
||||
});
|
||||
|
||||
return OldDropdownMenu;
|
||||
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
.o_graph_controller {
|
||||
.o_graph_renderer {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
.o_graph_canvas_container {
|
||||
padding-top: 5px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@@ -13,4 +13,69 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.o_tooltip_legend {
|
||||
background-color: white;
|
||||
color:black;
|
||||
font-size: 12px;
|
||||
border: black solid 0.5px;
|
||||
border-radius: 3px;
|
||||
opacity: 1;
|
||||
position: absolute;
|
||||
padding:10px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
div.o_graph_custom_tooltip {
|
||||
border-radius: 3px;
|
||||
background-color: white;
|
||||
border: black solid 0.5px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
padding: 6px;
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
font-family: Arial;
|
||||
table {
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th.o_measure {
|
||||
color: black;
|
||||
font-weight: bolder;
|
||||
vertical-align: baseline;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
td {
|
||||
span.o_square {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
span.o_label {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
color: black;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
&.o_value {
|
||||
color: black;
|
||||
padding-left: 5px;
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
tr.o_show_more {
|
||||
text-align: center;
|
||||
color: black;
|
||||
font-weight: bolder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +399,27 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div t-name="GraphView.CustomTooltip" class="o_graph_custom_tooltip">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="o_measure"><t t-esc="measure"/></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="tooltipItems" t-as="tooltipItem">
|
||||
<td>
|
||||
<span class="o_square" t-att-style="'background-color: ' + tooltipItem.boxColor"/>
|
||||
<span class="o_label" t-att-style="'max-width: ' + maxWidth"> <t t-esc="tooltipItem.label" /> </span>
|
||||
</td>
|
||||
<td class="o_value">
|
||||
<t t-esc="tooltipItem.value"/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div t-name="PivotView" t-attf-class="o_pivot #{widget.enableLinking ? 'o_enable_linking' : ''}">
|
||||
<div class="o_field_selection"/>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,7 @@ QUnit.module('Views', {
|
||||
data: [3,5],
|
||||
label: "Count",
|
||||
originIndex: 0,
|
||||
stack: "",
|
||||
stack: undefined,
|
||||
}
|
||||
);
|
||||
assert.checkLegend(graph, 'Count');
|
||||
|
||||
Reference in New Issue
Block a user