[IMP] web: use Chart.js in graph view

It has been decided to use Chart.js instead of nvd3 to render charts.
In this commit the graph view has been largely rewritten to benefit
from the options offered by Chart.js.

Task ID: 1946138
This commit is contained in:
Mathieu Duckerts-Antoine
2019-04-01 14:40:51 +00:00
parent a8d3ca771f
commit 604c43640a
11 changed files with 1787 additions and 918 deletions
@@ -5,82 +5,64 @@ var fieldUtils = require('web.field_utils');
var Class = require('web.Class');
var DateClasses = Class.extend({
init: function (dateSets, interval) {
// dateSets are assumed pairwise disjoint and ordered naturally.
// at least one of them is non empty.
// we complete the first inhabited dateSet. Its elements will be
// the default representatives for the classes.
this._maximalLength = 1;
this._referenceIndex = null;
/**
* This small class offers a light API to manage equivalence classes of
* dates. Two dates in different dateSets are declared equivalent when
* their indexes are equal.
*
* @param {Array[]} dateSets, a list of list of dates
*/
init: function (dateSets) {
// At least one dateSet must be non empty.
// The completion of the first inhabited dateSet will serve as a reference set.
// The reference set elements will be the default representatives for the classes.
this.dateSets = dateSets;
this.referenceIndex = null;
for (var i = 0; i < dateSets.length; i++) {
var dateSet = dateSets[i];
if (dateSet.length && this._referenceIndex === null) {
this._referenceIndex = i;
}
if (dateSet.length > this._maximalLength) {
this._maximalLength = dateSet.length;
if (dateSet.length && this.referenceIndex === null) {
this.referenceIndex = i;
}
}
this._referenceSet = this._contructReferenceSet(dateSets[this._referenceIndex], interval);
this._dateClasses = this._constructDateClasses(dateSets);
},
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
representative: function (date, index) {
index = index || this._referenceIndex;
return this._dateClass(date)[index];
},
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
/**
* @param {moment[]} dateSet, ordered dateSet
* @param {number} num, indicates number of dates to add
* @param {string} interval, determines time interval between two added dates
* @returns {function}
* Returns the index of a date in a given datesetIndex. This can be considered
* as the date class itself.
*
* @param {number} datesetIndex
* @param {string} date
* @return {number}
*/
_contructReferenceSet: function (dateSet, interval) {
var additionalDates = [];
var dateSetLength = dateSet.length;
var diff = this._maximalLength - dateSetLength;
var lastDate = dateSet[dateSetLength - 1];
for (var i = 0; i < diff; i++) {
var date = moment(lastDate).add(i + 1, interval).format('DD MMM YYYY');
additionalDates.push(date);
}
return dateSet.concat(additionalDates);
dateClass: function (datesetIndex, date) {
return this.dateSets[datesetIndex].indexOf(date);
},
_constructDateClasses: function (dateSets) {
var dateClasses = [];
for (var index = 0; index < this._maximalLength; index++) {
var dateClass = [];
for (var j = 0; j < dateSets.length; j++) {
var dateSet = j=== this._referenceIndex ? this._referenceSet : dateSets[j];
if (index < dateSet.length) {
dateClass.push(dateSet[index]);
} else {
dateClass.push(undefined);
}
}
dateClasses.push(dateClass);
}
return dateClasses;
/**
* returns the dates occuring in a given class
*
* @param {number} dateClass
* @return {string[]}
*/
dateClassMembers: function (dateClass) {
return _.uniq(_.compact(this.dateSets.map(function (dateSet) {
return dateSet[dateClass];
})));
},
_dateClass: function (date) {
var dateClass;
for (var i = 0; i < this._dateClasses.length; i++) {
dateClass = this._dateClasses[i];
if (_.contains(dateClass, date)) {
break;
}
}
return dateClass;
/**
* return the representative of a date class from a date set specified by an
* index.
*
* @param {number} dateClass
* @param {number} [index]
* @return {string}
*/
representative: function (dateClass, index) {
index = index === undefined ? this.referenceIndex : index;
return this.dateSets[index][dateClass];
},
});
/**
@@ -114,12 +114,11 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{
_setGroupby: function (groupBy) {
this.update({groupBy: groupBy});
},
/**
* @todo remove this and directly calls update. Update should be overridden
* and modified to call _updateButtons
*
* private
* @private
*
* @param {'pie'|'line'|'bar'} mode
*/
@@ -138,6 +137,15 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{
self._updateButtons();
});
},
/**
* @private
*
* @param {boolean} stacked
*/
_toggleStackMode: function (stacked) {
this.update({stacked: stacked});
this._updateButtons();
},
/**
* override
*
@@ -160,6 +168,11 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{
this.$buttons
.find('.o_graph_button[data-mode="' + state.mode + '"]')
.addClass('active');
this.$buttons
.find('.o_graph_button[data-mode="stack"]')
.data('stacked', state.stacked)
.toggleClass('active', state.stacked)
.toggleClass('o_hidden', state.mode !== 'bar');
_.each(this.$measureList.find('.dropdown-item'), function (item) {
var $item = $(item);
$item.toggleClass('selected', $item.data('field') === state.measure);
@@ -180,7 +193,11 @@ var GraphController = AbstractController.extend(GroupByMenuMixin,{
var $target = $(ev.target);
var field;
if ($target.hasClass('o_graph_button')) {
this._setMode($target.data('mode'));
if (_.contains(['bar','line', 'pie'], $target.data('mode'))) {
this._setMode($target.data('mode'));
} else if ($target.data('mode') === 'stack') {
this._toggleStackMode(!$target.data('stacked'));
}
} else if ($target.parents('.o_graph_measures_list').length) {
ev.preventDefault();
ev.stopPropagation();
@@ -25,12 +25,18 @@ return AbstractModel.extend({
//--------------------------------------------------------------------------
/**
* override
*
* We defend against outside modifications by extending the chart data. It
* may be overkill.
*
* @override
* @returns {Object}
*/
get: function () {
return this.chart;
var self = this;
return _.extend({}, this.chart, {
comparisonFieldIndex: self._getComparisonFieldIndex(),
});
},
/**
* Initial loading.
@@ -44,7 +50,9 @@ return AbstractModel.extend({
* @param {Object} params.fields
* @param {string[]} params.comparisonTimeRange
* @param {string[]} params.domain
* @param {string[]} params.groupBy a list of valid field names
* @param {string[]} params.groupBys a list of valid field names
* @param {string[]} params.groupedBy a list of valid field names
* @param {boolean} params.stacked
* @param {string[]} params.timeRange
* @param {string} params.comparisonField
* @param {string} params.comparisonTimeRangeDescription
@@ -72,6 +80,7 @@ return AbstractModel.extend({
measure: params.context.graph_measure || params.measure,
mode: params.context.graph_mode || params.mode,
origins: [],
stacked: params.stacked,
timeRange: params.timeRange,
timeRangeDescription: params.timeRangeDescription,
};
@@ -86,6 +95,7 @@ return AbstractModel.extend({
*
* @param {any} handle ignored!
* @param {Object} params
* @param {boolean} [params.stacked]
* @param {Object} [params.context]
* @param {string[]} [params.domain]
* @param {string[]} [params.groupBy]
@@ -129,6 +139,10 @@ return AbstractModel.extend({
this.chart.mode = params.mode;
return Promise.resolve();
}
if ('stacked' in params) {
this.chart.stacked = params.stacked;
return Promise.resolve();
}
return this._loadGraph(this._getDomains());
},
@@ -137,24 +151,35 @@ return AbstractModel.extend({
//--------------------------------------------------------------------------
/**
* @returns {Object[]}
* @private
* @returns {number}
*/
_getComparisonFieldIndex: function () {
var groupBys = this.chart.groupBy.map(function (gb) {
return gb.split(":")[0];
});
return groupBys.indexOf(this.chart.comparisonField);
},
/**
* @private
* @returns {Array[]}
*/
_getDomains: function () {
var domains = [this.chart.domain.concat(this.chart.timeRange)];
this.chart.origins = [this.chart.timeRangeDescription];
this.chart.origins = [this.chart.timeRangeDescription || ""];
if (this.chart.compare) {
domains.push(this.chart.domain.concat(this.chart.comparisonTimeRange));
this.chart.origins.push(this.chart.comparisonTimeRangeDescription);
}
return domains;
},
/**
* Fetch and process graph data. It is basically a(some) read_group(s)
* with correct fields for each domain. We have to do some light processing
* to separate date groups in the field list, because they can be defined
* with an aggregation function, such as my_date:week.
*
* @private
* @param {Array[]} domains
* @returns {Promise}
*/
@@ -200,6 +225,7 @@ return AbstractModel.extend({
* the object this.chart in argument, or an array or something. We want to
* avoid writing on a this.chart object modified by a subsequent read_group
*
* @private
* @param {number} originIndex
* @param {any} rawData result from the read_group
*/
@@ -239,6 +265,7 @@ return AbstractModel.extend({
* Helper function (for _processData), turns various values in a usable
* string form, that we can display in the interface.
*
* @private
* @param {any} value value for the field fieldName received by the read_group rpc
* @param {string} fieldName
* @returns {string}
File diff suppressed because it is too large Load Diff
@@ -22,13 +22,8 @@ var GROUPABLE_TYPES =
var GraphView = AbstractView.extend({
display_name: _lt('Graph'),
icon: 'fa-bar-chart',
cssLibs: [
'/web/static/lib/nvd3/nv.d3.css'
],
jsLibs: [
'/web/static/lib/nvd3/d3.v3.js',
'/web/static/lib/nvd3/nv.d3.js',
'/web/static/src/js/libs/nvd3.js'
'/web/static/lib/Chart/Chart.js',
],
config: {
Model: GraphModel,
@@ -80,7 +75,6 @@ var GraphView = AbstractView.extend({
this.controllerParams.measures = measures;
this.controllerParams.groupableFields = groupableFields;
this.rendererParams.fields = this.fields;
this.rendererParams.stacked = this.arch.attrs.stacked !== "False";
this.rendererParams.title = this.arch.attrs.title; // TODO: use attrs.string instead
this.loadParams.mode = this.arch.attrs.type || 'bar';
@@ -88,6 +82,7 @@ var GraphView = AbstractView.extend({
this.loadParams.groupBys = groupBys;
this.loadParams.fields = this.fields;
this.loadParams.comparisonDomain = params.comparisonDomain;
this.loadParams.stacked = this.arch.attrs.stacked !== "False";
},
});
+6 -53
View File
@@ -2,62 +2,15 @@
.o_graph_renderer {
height: 100%;
overflow: auto;
.o_graph_svg_container {
direction: ltr#{"/*rtl:ignore*/"};
.o_graph_canvas_container {
position: relative;
height: 100%;
overflow: auto;
svg {
width: 100%;
top: 0px;
left: 0px;
canvas {
background-color: $o-view-background-color;
.nv-y {
path.domain, g.tick.zero {
// Hide y axis line and zero tick label
display: none;
}
}
.nv-axis.nv-x path.domain {
stroke-opacity: 0.2;
}
.nv-groups g.nv-group {
stroke-width:10px;
}
.nv-groups path.nv-area {
// ligther area color
fill-opacity: 0.1;
}
.nv-lineChart .nv-x.nv-axis .nv-axisMaxMin-x {
&.nv-axisMin-x text {
text-anchor: start !important;
}
&.nv-axisMax-x text {
text-anchor: end !important;
}
}
}
/* Use less than 50% of the screen area for tooltip and show legends in 3 column of 33% each */
&.o_tooltip_split_in_columns .nvtooltip {
max-width: 48%;
tbody {
display: flex;
flex-flow: wrap;
tr {
width: 33%;
td {
display: inline-block;
&.key {
min-width: 160px;
max-width: 70%;
overflow-x: hidden;
text-overflow: ellipsis;
}
}
}
}
}
}
}
}
.nvtooltip thead {
background-color: transparent;
}
+3
View File
@@ -521,6 +521,9 @@
<button class="btn btn-secondary fa fa-area-chart o_graph_button" title="Line Chart" aria-label="Line Chart" data-mode="line"/>
<button class="btn btn-secondary fa fa-pie-chart o_graph_button" title="Pie Chart" aria-label="Pie Chart" data-mode="pie"/>
</div>
<div class="btn-group" role="toolbar" aria-label="Change graph">
<button class="btn btn-secondary fa fa-database o_graph_button" title="Stacked" aria-label="Stacked" data-mode="stack"/>
</div>
</t>
<div t-name="GraphView.error" class="o_view_nocontent" role="alert">
<div class="o_nocontent_help">
-29
View File
@@ -7,33 +7,4 @@
</graph>
</t>
<t t-name="web.Chart.Tooltip">
<table>
<thead>
<tr>
<td colspan="3">
<strong class="x-value">
<t t-esc="title"/>
</strong>
</td>
</tr>
</thead>
<tbody>
<t t-foreach="lines" t-as="line">
<tr>
<td class="legend-color-guide">
<div t-att-style="'background-color:' + line.color +';'"/>
</td>
<td class="key">
<t t-esc="line.label"/>
</td>
<td class="value">
<t t-esc="line.value &amp;&amp; window.parseFloat(line.value).toFixed(2) || 0"/>
</td>
</tr>
</t>
</tbody>
</table>
</t>
</template>
@@ -0,0 +1,75 @@
odoo.define('web.data_comparison_utils_tests', function(require) {
"use strict";
var dataComparisonUtils = require('web.dataComparisonUtils');
var DateClasses = dataComparisonUtils.DateClasses;
QUnit.module('dataComparisonUtils', function () {
QUnit.module('DateClasses');
QUnit.test('main parameters are correctly computed', function(assert) {
assert.expect(30);
var dateClasses;
dateClasses = new DateClasses([['2019']]);
assert.strictEqual(dateClasses.referenceIndex, 0);
assert.strictEqual(dateClasses.dateClass(0, '2019'), 0);
assert.deepEqual(dateClasses.dateClassMembers(0), ['2019']);
dateClasses = new DateClasses([['2018', '2019']]);
assert.strictEqual(dateClasses.referenceIndex, 0);
assert.strictEqual(dateClasses.dateClass(0, '2018'), 0);
assert.strictEqual(dateClasses.dateClass(0, '2019'), 1);
assert.deepEqual(dateClasses.dateClassMembers(0), ['2018']);
assert.deepEqual(dateClasses.dateClassMembers(1), ['2019']);
dateClasses = new DateClasses([['2019'], []]);
assert.strictEqual(dateClasses.referenceIndex, 0);
assert.strictEqual(dateClasses.dateClass(0, '2019'), 0);
assert.deepEqual(dateClasses.dateClassMembers(0), ['2019']);
dateClasses = new DateClasses([[], ['2019']]);
assert.strictEqual(dateClasses.referenceIndex, 1);
assert.strictEqual(dateClasses.dateClass(1, '2019'), 0);
assert.deepEqual(dateClasses.dateClassMembers(0), ['2019']);
dateClasses = new DateClasses([['2019'],['2018', '2019']]);
assert.strictEqual(dateClasses.referenceIndex, 0);
assert.strictEqual(dateClasses.dateClass(0, '2019'), 0);
assert.strictEqual(dateClasses.dateClass(1, '2018'), 0);
assert.strictEqual(dateClasses.dateClass(1, '2019'), 1);
assert.deepEqual(dateClasses.dateClassMembers(0), ['2019', '2018']);
assert.deepEqual(dateClasses.dateClassMembers(1), ['2019']);
dateClasses = new DateClasses([['2019'], ['2017', '2018', '2020'], ['2017', '2019']]);
assert.strictEqual(dateClasses.referenceIndex, 0);
assert.strictEqual(dateClasses.dateClass(0, '2019'), 0);
assert.strictEqual(dateClasses.dateClass(1, '2017'), 0);
assert.strictEqual(dateClasses.dateClass(1, '2018'), 1);
assert.strictEqual(dateClasses.dateClass(1, '2020'), 2);
assert.strictEqual(dateClasses.dateClass(2, '2017'), 0);
assert.strictEqual(dateClasses.dateClass(2, '2019'), 1);
assert.deepEqual(dateClasses.dateClassMembers(0), ['2019', '2017']);
assert.deepEqual(dateClasses.dateClassMembers(1), ['2018', '2019']);
assert.deepEqual(dateClasses.dateClassMembers(2), ['2020']);
});
QUnit.test('two overlapping datesets and classes representatives', function(assert) {
assert.expect(4);
var dateClasses = new DateClasses([['March 2017'], ['February 2017', 'March 2017']]);
assert.strictEqual(dateClasses.representative(0, 0), 'March 2017');
assert.strictEqual(dateClasses.representative(0, 1), 'February 2017');
assert.strictEqual(dateClasses.representative(1, 0), undefined);
assert.strictEqual(dateClasses.representative(1, 1), 'March 2017');
});
});
});
File diff suppressed because it is too large Load Diff
+1
View File
@@ -577,6 +577,7 @@
<script type="text/javascript" src="/web/static/tests/core/class_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/rpc_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/domain_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/data_comparison_utils_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/mixins_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/time_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/concurrency_tests.js"></script>