flectra/addons/web/static/src/js/views/graph/graph_renderer.js

365 lines
12 KiB
JavaScript

flectra.define('web.GraphRenderer', function (require) {
"use strict";
/**
* The graph renderer turns the data from the graph model into a nice looking
* svg chart. This code uses the nvd3 library.
*
* Note that we use a custom build for the nvd3, with only the model we actually
* use.
*/
var AbstractRenderer = require('web.AbstractRenderer');
var config = require('web.config');
var core = require('web.core');
var field_utils = require('web.field_utils');
var _t = core._t;
var qweb = core.qweb;
var CHART_TYPES = ['pie', 'bar', 'line'];
// hide top legend when too many items for device size
var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class);
return AbstractRenderer.extend({
className: "o_graph_svg_container",
/**
* @override
* @param {Widget} parent
* @param {Object} state
* @param {Object} params
* @param {boolean} params.stacked
*/
init: function (parent, state, params) {
this._super.apply(this, arguments);
this.stacked = params.stacked;
this.$el.css({minWidth: '100px', minHeight: '100px'});
},
/**
* @override
*/
destroy: function () {
nv.utils.offWindowResize(this.to_remove);
this._super();
},
/**
* The graph view uses the nv(d3) 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.
*
* @override
*/
on_attach_callback: function () {
this._super.apply(this, arguments);
this.isInDOM = true;
this._renderGraph();
},
/**
* @override
*/
on_detach_callback: function () {
this._super.apply(this, arguments);
this.isInDOM = false;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Render the chart.
*
* Note that This method is synchronous, but the actual rendering is done
* asynchronously. The reason for that is that nvd3/d3 needs to be in the
* DOM to correctly render itself. So, we trick Flectra by returning
* immediately, then we render the chart when the widget is in the DOM.
*
* @returns {Deferred} The _super deferred is actually resolved immediately
*/
_render: function () {
if (this.to_remove) {
nv.utils.offWindowResize(this.to_remove);
}
if (!_.contains(CHART_TYPES, this.state.mode)) {
this.$el.empty();
this.trigger_up('warning', {
title: _t('Invalid mode for chart'),
message: _t('Cannot render chart with mode : ') + this.state.mode
});
} else if (!this.state.data.length) {
this.$el.empty();
this.$el.append(qweb.render('GraphView.error', {
title: _t("No data to display"),
description: _t("No data available for this chart. " +
"Try to add some records, or make sure that " +
"there is no active filter in the search bar."),
}));
} else if (this.isInDOM) {
// 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')
this._renderGraph();
}
return this._super.apply(this, arguments);
},
/**
* Helper function to set up data properly for the multiBarChart model in
* nvd3.
*
* @returns {nvd3 chart}
*/
_renderBarChart: function () {
// prepare data for bar chart
var self = this;
var data, values;
var measure = this.state.fields[this.state.measure].string;
// zero groupbys
if (this.state.groupedBy.length === 0) {
data = [{
values: [{
x: measure,
y: this.state.data[0].value}],
key: measure
}];
}
// one groupby
if (this.state.groupedBy.length === 1) {
values = this.state.data.map(function (datapt) {
return {x: datapt.labels, y: datapt.value};
});
data = [
{
values: values,
key: measure,
}
];
}
if (this.state.groupedBy.length > 1) {
var xlabels = [],
series = [],
label, serie, value;
values = {};
for (var i = 0; i < this.state.data.length; i++) {
label = this.state.data[i].labels[0];
serie = this.state.data[i].labels[1];
value = this.state.data[i].value;
if ((!xlabels.length) || (xlabels[xlabels.length-1] !== label)) {
xlabels.push(label);
}
series.push(this.state.data[i].labels[1]);
if (!(serie in values)) {values[serie] = {};}
values[serie][label] = this.state.data[i].value;
}
series = _.uniq(series);
data = [];
var current_serie, j;
for (i = 0; i < series.length; i++) {
current_serie = {values: [], key: series[i]};
for (j = 0; j < xlabels.length; j++) {
current_serie.values.push({
x: xlabels[j],
y: values[series[i]][xlabels[j]] || 0,
});
}
data.push(current_serie);
}
}
var svg = d3.select(this.$el[0]).append('svg');
svg.datum(data);
svg.transition().duration(0);
var chart = nv.models.multiBarChart();
chart.options({
margin: {left: 80, bottom: 100, top: 80, right: 0},
delay: 100,
transition: 10,
showLegend: _.size(data) <= MAX_LEGEND_LENGTH,
showXAxis: true,
showYAxis: true,
rightAlignYAxis: false,
stacked: this.stacked,
reduceXTicks: false,
rotateLabels: -20,
showControls: (this.state.groupedBy.length > 1)
});
chart.yAxis.tickFormat(function (d) {
var measure_field = self.state.fields[self.measure];
return field_utils.format.float(d, {
digits: measure_field && measure_field.digits || [69, 2],
});
});
chart(svg);
return chart;
},
/**
* Helper function to set up data properly for the pieChart model in
* nvd3.
*
* @returns {nvd3 chart}
*/
_renderPieChart: function () {
var data = [];
var all_negative = true;
var some_negative = false;
var all_zero = true;
this.state.data.forEach(function (datapt) {
all_negative = all_negative && (datapt.value < 0);
some_negative = some_negative || (datapt.value < 0);
all_zero = all_zero && (datapt.value === 0);
});
if (some_negative && !all_negative) {
this.$el.append(qweb.render('GraphView.error', {
title: _t("Invalid data"),
description: _t("Pie chart cannot mix positive and negative numbers. " +
"Try to change your domain to only display positive results"),
}));
return;
}
if (all_zero) {
this.$el.append(qweb.render('GraphView.error', {
title: _t("Invalid data"),
description: _t("Pie chart cannot display all zero numbers.. " +
"Try to change your domain to display positive results"),
}));
return;
}
if (this.state.groupedBy.length) {
data = this.state.data.map(function (datapt) {
return {x:datapt.labels.join("/"), y: datapt.value};
});
}
var svg = d3.select(this.$el[0]).append('svg');
svg.datum(data);
svg.transition().duration(100);
var legend_right = config.device.size_class > config.device.SIZES.XS;
var chart = nv.models.pieChart().labelType('percent');
chart.options({
delay: 250,
showLegend: legend_right || _.size(data) <= MAX_LEGEND_LENGTH,
legendPosition: legend_right ? 'right' : 'top',
transition: 100,
color: d3.scale.category10().range(),
});
chart(svg);
return chart;
},
/**
* Helper function to set up data properly for the line model in
* nvd3.
*
* @returns {nvd3 chart}
*/
_renderLineChart: function () {
if (this.state.data.length < 2) {
this.$el.append(qweb.render('GraphView.error', {
title: _t("Not enough data points"),
description: "You need at least two data points to display a line chart."
}));
return;
}
var self = this;
var data = [];
var tickValues;
var tickFormat;
var measure = this.state.fields[this.state.measure].string;
if (this.state.groupedBy.length === 1) {
var values = this.state.data.map(function (datapt, index) {
return {x: index, y: datapt.value};
});
data = [
{
values: values,
key: measure,
}
];
tickValues = this.state.data.map(function (d, i) { return i;});
tickFormat = function (d) {return self.state.data[d].labels;};
}
if (this.state.groupedBy.length > 1) {
data = [];
var data_dict = {};
var tick = 0;
var tickLabels = [];
var serie, tickLabel;
var identity = function (p) {return p;};
tickValues = [];
for (var i = 0; i < this.state.data.length; i++) {
if (this.state.data[i].labels[0] !== tickLabel) {
tickLabel = this.state.data[i].labels[0];
tickValues.push(tick);
tickLabels.push(tickLabel);
tick++;
}
serie = this.state.data[i].labels[1];
if (!data_dict[serie]) {
data_dict[serie] = {
values: [],
key: serie,
};
}
data_dict[serie].values.push({
x: tick, y: this.state.data[i].value,
});
data = _.map(data_dict, identity);
}
tickFormat = function (d) {return tickLabels[d];};
}
var svg = d3.select(this.$el[0]).append('svg');
svg.datum(data);
svg.transition().duration(0);
var chart = nv.models.lineChart();
chart.options({
margin: {left: 80, bottom: 100, top: 80, right: 0},
useInteractiveGuideline: true,
showLegend: _.size(data) <= MAX_LEGEND_LENGTH,
showXAxis: true,
showYAxis: true,
});
chart.xAxis.tickValues(tickValues)
.tickFormat(tickFormat);
chart.yAxis.tickFormat(function (d) {
return field_utils.format.float(d, {
digits : self.state.fields[self.state.measure] && self.state.fields[self.state.measure].digits || [69, 2],
});
});
chart(svg);
return chart;
},
/**
* Renders the graph according to its type. This function must be called
* when the renderer is in the DOM (for nvd3 to render the graph correctly).
*
* @private
*/
_renderGraph: function () {
this.$el.empty();
var chart = this['_render' + _.str.capitalize(this.state.mode) + 'Chart']();
if (chart && chart.tooltip.chartContainer) {
this.to_remove = chart.update;
nv.utils.onWindowResize(chart.update);
chart.tooltip.chartContainer(this.el);
}
},
});
});