diff options
| author | Géry Debongnie <ged@odoo.com> | 2014-12-19 13:38:37 +0100 |
|---|---|---|
| committer | Géry Debongnie <ged@odoo.com> | 2014-12-19 14:34:23 +0100 |
| commit | 2c08dc0ba610db61fe349d1602c12528ca8fc715 (patch) | |
| tree | 143cb7f61e45e027c63be321ee3d4031963df1e7 /addons/web/static/src/js/view_graph.js | |
| parent | 9f7a0c096f0d3deabf8baf7be33b108f5b7884b5 (diff) | |
[IMP] web: new pivot view, full rewrite of graph view
This commit is a split of the former graphview into a pivot view (the
tabular view) and graph view (the various charts). The code was fully
rewritten.
* pivot view should be faster (better underlying data structure and
better algorithm)
* better integration with the web client (use of sidebar, of menu buttons,
bettor style)
* new feature: pivot view can be sorted
* new feature: pivot view cells can be clicked to open a list view with
the corresponding domain
* web_graph addon was removed, and the graph view code has been moved
to the addon web.
* the interaction with the searchbar was changed (no more column facet)
* better and simpler code for graph view
* mary corner cases in graph charts have been corrected (for ex, pie
chart with positive and negative numbers)
* graph/pivot correctly save their state when added to dashboard/saved
(state such as current measure, chart mode, column groupbys, ...)
Diffstat (limited to 'addons/web/static/src/js/view_graph.js')
| -rw-r--r-- | addons/web/static/src/js/view_graph.js | 446 |
1 files changed, 446 insertions, 0 deletions
diff --git a/addons/web/static/src/js/view_graph.js b/addons/web/static/src/js/view_graph.js new file mode 100644 index 00000000000..dea16c3396c --- /dev/null +++ b/addons/web/static/src/js/view_graph.js @@ -0,0 +1,446 @@ +/*--------------------------------------------------------- + * Odoo Pivot Table view + *---------------------------------------------------------*/ + +(function (a, b, c) { +'use strict'; + +var instance = openerp, + _lt = instance.web._lt, + _t = instance.web._t, + QWeb = instance.web.qweb; + +nv.dev = false; // sets nvd3 library in production mode + +instance.web.views.add('graph', 'instance.web.GraphView'); + +instance.web.GraphView = instance.web.View.extend({ + className: 'oe_graph', + display_name: _lt('Graph'), + view_type: 'graph', + + init: function(parent, dataset, view_id, options) { + this._super(parent, dataset, view_id, options); + + this.model = new instance.web.Model(dataset.model, {group_by_no_leaf: true}); + this.measures = []; + this.active_measure = '__count__'; + this.initial_groupbys = []; + this.$buttons = options.$buttons; + this.widget = undefined; + }, + start: function () { + var load_fields = this.model.call('fields_get', []) + .then(this.prepare_fields.bind(this)); + + return $.when(this._super(), load_fields).then(this.render_buttons.bind(this)); + }, + render_buttons: function () { + var context = {measures: _.pairs(_.omit(this.measures, '__count__'))}; + this.$buttons.html(QWeb.render('GraphView.buttons', context)); + this.$measure_list = this.$buttons.find('.oe-measure-list'); + this.update_measure(); + this.$buttons.find('button').tooltip(); + this.$buttons.click(this.on_button_click.bind(this)); + }, + update_measure: function () { + var self = this; + this.$measure_list.find('li').each(function (index, li) { + $(li).toggleClass('selected', $(li).data('field') === self.active_measure); + }); + }, + view_loading: function (fvg) { + var self = this; + fvg.arch.children.forEach(function (field) { + var name = field.attrs.name; + if (field.attrs.interval) { + name += ':' + field.attrs.interval; + } + if (field.attrs.type === 'measure') { + self.active_measure = name; + } else { + self.initial_groupbys.push(name); + } + }); + }, + do_show: function () { + this.do_push_state({}); + this.$el.show(); + return this._super(); + }, + prepare_fields: function (fields) { + var self = this; + this.fields = fields; + _.each(fields, function (field, name) { + if ((name !== 'id') && (field.store === true)) { + if (field.type === 'integer' || field.type === 'float') { + self.measures[name] = field; + } + } + }); + this.measures.__count__ = {string: _t("Quantity"), type: "integer"}; + }, + do_search: function (domain, context, group_by) { + if (!this.widget) { + this.initial_groupbys = context.graph_groupbys || this.initial_groupbys; + this.widget = new instance.web.GraphWidget(this, this.dataset.model, { + measure: context.graph_measure || this.active_measure, + mode: context.graph_mode || this.active_mode, + domain: domain, + groupbys: this.initial_groupbys, + context: context, + fields: this.fields, + }); + // append widget + this.$el.hide(); + this.widget.appendTo(this.$el); + } else { + var groupbys = group_by.length ? group_by : this.initial_groupbys.slice(0); + this.widget.update_data(domain, groupbys); + } + }, + get_context: function () { + return !this.widget ? {} : { + graph_mode: this.widget.mode, + graph_measure: this.widget.measure, + graph_groupbys: this.widget.groupbys + }; + }, + on_button_click: function (event) { + var $target = $(event.target); + if ($target.hasClass('oe-bar-mode')) {this.widget.set_mode('bar');} + if ($target.hasClass('oe-line-mode')) {this.widget.set_mode('line');} + if ($target.hasClass('oe-pie-mode')) {this.widget.set_mode('pie');} + if ($target.parents('.oe-measure-list').length) { + var parent = $target.parent(), + field = parent.data('field'); + this.active_measure = field; + parent.toggleClass('selected'); + event.stopPropagation(); + this.update_measure(); + this.widget.set_measure(this.active_measure); + } + }, +}); + +instance.web.GraphWidget = instance.web.Widget.extend({ + init: function (parent, model, options) { + this._super(parent); + this.context = options.context; + this.fields = options.fields; + this.fields.__count__ = _t("Quantity"); + this.model = new instance.web.Model(model, {group_by_no_leaf: true}); + + this.domain = options.domain || []; + this.groupbys = options.groupbys || []; + this.mode = options.mode || "bar"; + this.measure = options.measure || "__count__"; + }, + start: function () { + return this.load_data().then(this.proxy('display_graph')); + }, + update_data: function (domain, groupbys) { + this.domain = domain; + this.groupbys = groupbys; + return this.load_data().then(this.proxy('display_graph')); + }, + set_mode: function (mode) { + this.mode = mode; + this.display_graph(); + }, + set_measure: function (measure) { + this.measure = measure; + return this.load_data().then(this.proxy('display_graph')); + }, + load_data: function () { + var fields = this.groupbys.slice(0); + if (this.measure !== '__count__'.slice(0)) + fields = fields.concat(this.measure); + return this.model + .query(fields) + .filter(this.domain) + .context(this.context) + .lazy(false) + .group_by(this.groupbys.slice(0,2)) + .then(this.proxy('prepare_data')); + }, + prepare_data: function () { + var raw_data = arguments[0], + is_count = this.measure === '__count__'; + var data_pt, j, values, value; + + this.data = []; + for (var i = 0; i < raw_data.length; i++) { + data_pt = raw_data[i].attributes; + values = []; + if (this.groupbys.length === 1) data_pt.value = [data_pt.value]; + for (j = 0; j < data_pt.value.length; j++) { + values[j] = this.sanitize_value(data_pt.value[j], data_pt.grouped_on[j]); + } + value = is_count ? data_pt.length : data_pt.aggregates[this.measure]; + this.data.push({ + labels: values, + value: value + }); + } + }, + sanitize_value: function (value, field) { + if (value === false) return _t("Undefined"); + if (value instanceof Array) return value[1]; + if (field && this.fields[field] && (this.fields[field].type === 'selection')) { + var selected = _.where(this.fields[field].selection, {0: value})[0]; + return selected ? selected[1] : value; + } + return value; + }, + display_graph: function () { + if (this.to_remove) { + nv.utils.offWindowResize(this.to_remove); + } + this.$el.empty(); + if (!this.data.length) { + 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 { + this['display_' + this.mode](); + } + }, + display_bar: function () { + // prepare data for bar chart + var data, values, + measure = this.fields[this.measure].string; + // zero groupbys + if (this.groupbys.length === 0) { + data = [{ + values: [{ + x: this.ViewManager.title, + y: this.data[0].value}], + key: measure + }]; + } + // one groupby + if (this.groupbys.length === 1) { + values = this.data.map(function (datapt) { + return {x: datapt.labels, y: datapt.value}; + }); + data = [ + { + values: values, + key: measure, + } + ]; + } + if (this.groupbys.length > 1) { + var xlabels = [], + series = [], + label, serie, value; + values = {}; + for (var i = 0; i < this.data.length; i++) { + label = this.data[i].labels[0]; + serie = this.data[i].labels[1]; + value = this.data[i].value; + if ((!xlabels.length) || (xlabels[xlabels.length-1] !== label)) { + xlabels.push(label); + } + series.push(this.data[i].labels[1]); + if (!(serie in values)) {values[serie] = {};} + values[serie][label] = this.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({ + delay: 250, + transitionDuration: 10, + showLegend: true, + showXAxis: true, + showYAxis: true, + rightAlignYAxis: false, + stacked: true, + reduceXTicks: false, + // rotateLabels: 40, + showControls: (this.groupbys.length > 1) + }); + + chart(svg); + this.to_remove = chart.update; + nv.utils.onWindowResize(chart.update); + }, + display_pie: function () { + var data = [], + tickValues, + tickFormat, + measure = this.fields[this.measure].string; + + var all_negative = true, + some_negative = false, + all_zero = true; + this.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) { + return 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"), + })); + } + if (all_zero) { + return 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"), + })); + } + if (this.groupbys.length) { + data = this.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 chart = nv.models.pieChart(); + chart.options({ + delay: 250, + transitionDuration: 100, + color: d3.scale.category10().range(), + }); + + chart(svg); + this.to_remove = chart.update; + nv.utils.onWindowResize(chart.update); + }, + display_line: function () { + if (this.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, + data = [], + tickValues, + tickFormat, + measure = this.fields[this.measure].string; + if (this.groupbys.length === 1) { + var values = this.data.map(function (datapt, index) { + return {x: index, y: datapt.value}; + }); + data = [ + { + values: values, + key: measure, + } + ]; + tickValues = this.data.map(function (d, i) { return i;}); + tickFormat = function (d) {return self.data[d].labels;}; + } + if (this.groupbys.length > 1) { + data = []; + var data_dict = {}, + tick = 0, + tickLabels = [], + serie, tickLabel, + identity = function (p) {return p;}; + tickValues = []; + for (var i = 0; i < this.data.length; i++) { + if (this.data[i].labels[0] !== tickLabel) { + tickLabel = this.data[i].labels[0]; + tickValues.push(tick); + tickLabels.push(tickLabel); + tick++; + } + serie = this.data[i].labels[1]; + if (!data_dict[serie]) { + data_dict[serie] = { + values: [], + key: serie, + }; + } + data_dict[serie].values.push({ + x: tick, y: this.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: 50, right: 50}, + useInteractiveGuideline: true, + showLegend: true, + showXAxis: true, + showYAxis: true, + }); + chart.xAxis.tickValues(tickValues) + .tickFormat(tickFormat); + + chart(svg); + this.to_remove = chart.update; + nv.utils.onWindowResize(chart.update); + }, + destroy: function () { + nv.utils.offWindowResize(this.to_remove); + return this._super(); + } +}); + +// monkey patch nvd3 to allow removing eventhandler on windowresize events +// see https://github.com/novus/nvd3/pull/396 for more details + +// Adds a resize listener to the window. +nv.utils.onWindowResize = function(fun) { + if (fun === null) return; + window.addEventListener('resize', fun); +}; + +// Backwards compatibility with current API. +nv.utils.windowResize = nv.utils.onWindowResize; + +// Removes a resize listener from the window. +nv.utils.offWindowResize = function(fun) { + if (fun === null) return; + window.removeEventListener('resize', fun); +}; + +// monkey patch nvd3 to prevent crashes when user changes view and nvd3 tries +// to remove tooltips after 500 ms... seriously nvd3, what were you thinking? +nv.tooltip.cleanup = function () { + $('.nvtooltip').remove(); +}; + + +})(); |
