/*jshint multistr:true */ this.recline = this.recline || {}; this.recline.View = this.recline.View || {}; (function($, my) { // ## Graph view for a Dataset using Flot graphing library. // // Initialization arguments (in a hash in first parameter): // // * model: recline.Model.Dataset // * state: (optional) configuration hash of form: // // { // group: {column name for x-axis}, // series: [{column name for series A}, {column name series B}, ... ], // graphType: 'line' // } // // NB: should *not* provide an el argument to the view but must let the view // generate the element itself (you can then append view.el to the DOM. my.Graph = Backbone.View.extend({ tagName: "div", className: "recline-graph", template: ' \
\
\

Hey there!

\

There\'s no graph here yet because we don\'t know what fields you\'d like to see plotted.

\

Please tell us by using the menu on the right and a graph will automatically appear.

\
\
\ \ ', initialize: function(options) { var self = this; this.graphColors = ["#edc240", "#afd8f8", "#cb4b4b", "#4da74d", "#9440ed"]; this.el = $(this.el); _.bindAll(this, 'render', 'redraw'); this.needToRedraw = false; this.model.bind('change', this.render); this.model.fields.bind('reset', this.render); this.model.fields.bind('add', this.render); this.model.records.bind('add', this.redraw); this.model.records.bind('reset', this.redraw); // because we cannot redraw when hidden we may need when becoming visible this.bind('view:show', function() { if (this.needToRedraw) { self.redraw(); } }); var stateData = _.extend({ group: null, // so that at least one series chooser box shows up series: [], graphType: 'lines-and-points' }, options.state ); this.state = new recline.Model.ObjectState(stateData); this.editor = new my.GraphControls({ model: this.model, state: this.state.toJSON() }); this.editor.state.bind('change', function() { self.state.set(self.editor.state.toJSON()); self.redraw(); }); this.elSidebar = this.editor.el; this.render(); }, render: function() { var self = this; var tmplData = this.model.toTemplateJSON(); var htmls = Mustache.render(this.template, tmplData); $(this.el).html(htmls); this.$graph = this.el.find('.panel.graph'); return this; }, redraw: function() { // There appear to be issues generating a Flot graph if either: // * The relevant div that graph attaches to his hidden at the moment of creating the plot -- Flot will complain with // // Uncaught Invalid dimensions for plot, width = 0, height = 0 // * There is no data for the plot -- either same error or may have issues later with errors like 'non-existent node-value' var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]); if ((!areWeVisible || this.model.records.length === 0)) { this.needToRedraw = true; return; } // check we have something to plot if (this.state.get('group') && this.state.get('series')) { // faff around with width because flot draws axes *outside* of the element width which means graph can get push down as it hits element next to it this.$graph.width(this.el.width() - 20); var series = this.createSeries(); var options = this.getGraphOptions(this.state.attributes.graphType); this.plot = Flotr.draw(this.$graph.get(0), series, options); } }, // ### getGraphOptions // // Get options for Flot Graph // // needs to be function as can depend on state // // @param typeId graphType id (lines, lines-and-points etc) getGraphOptions: function(typeId) { var self = this; // special tickformatter to show labels rather than numbers // TODO: we should really use tickFormatter and 1 interval ticks if (and // only if) x-axis values are non-numeric // However, that is non-trivial to work out from a dataset (datasets may // have no field type info). Thus at present we only do this for bars. var tickFormatter = function (val) { if (self.model.records.models[val]) { var out = self.model.records.models[val].get(self.state.attributes.group); // if the value was in fact a number we want that not the if (typeof(out) == 'number') { return val; } else { return out; } } return val; }; var trackFormatter = function (obj) { var x = obj.x; var y = obj.y; // it's horizontal so we have to flip if (self.state.attributes.graphType === 'bars') { var _tmp = x; x = y; y = _tmp; } // convert back from 'index' value on x-axis (e.g. in cases where non-number values) //if (self.model.records.models[x]) { // x = self.model.records.models[x].get(self.state.attributes.group); //}; // is it time series var xfield = self.model.fields.get(self.state.attributes.group); var isDateTime = xfield.get('type') === 'date'; if (isDateTime) { x = x.toLocaleDateString(); } var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', { group: self.state.attributes.group, x: x, series: obj.series.label, y: y }); return content; }; var xaxis = {}; // check for time series on x-axis if (this.model.fields.get(this.state.get('group')).get('type') === 'date') { xaxis.mode = 'time'; xaxis.timeformat = '%y-%b'; xaxis.autoscale = true; xaxis.autoscaleMargin = 0.02; }; var yaxis = {}; yaxis.autoscale = true; yaxis.autoscaleMargin = 0.02; var mouse = {}; mouse.track = true; mouse.relative = true; mouse.trackFormatter = trackFormatter; var legend = {}; legend.position = 'ne'; // mouse.lineColor is set in createSeries var optionsPerGraphType = { lines: { legend: legend, colors: this.graphColors, lines: { show: true }, xaxis: xaxis, yaxis: yaxis, mouse: mouse }, points: { legend: legend, colors: this.graphColors, points: { show: true, hitRadius: 5 }, xaxis: xaxis, yaxis: yaxis, mouse: mouse, grid: { hoverable: true, clickable: true } }, 'lines-and-points': { legend: legend, colors: this.graphColors, points: { show: true, hitRadius: 5 }, lines: { show: true }, xaxis: xaxis, yaxis: yaxis, mouse: mouse, grid: { hoverable: true, clickable: true } }, bars: { legend: legend, colors: this.graphColors, lines: { show: false }, yaxis: yaxis, mouse: { track: true, relative: true, trackFormatter: trackFormatter, fillColor: '#FFFFFF', fillOpacity: 0.3, position: 'e' }, bars: { show: true, horizontal: true, shadowSize: 0, barWidth: 0.8 }, }, columns: { legend: legend, colors: this.graphColors, lines: { show: false }, yaxis: yaxis, mouse: { track: true, relative: true, trackFormatter: trackFormatter, fillColor: '#FFFFFF', fillOpacity: 0.3, position: 'n' }, bars: { show: true, horizontal: false, shadowSize: 0, barWidth: 0.8 }, }, grid: { hoverable: true, clickable: true }, }; return optionsPerGraphType[typeId]; }, createSeries: function () { var self = this; var series = []; _.each(this.state.attributes.series, function(field) { var points = []; _.each(self.model.records.models, function(doc, index) { var xfield = self.model.fields.get(self.state.attributes.group); var x = doc.getFieldValue(xfield); // time series var isDateTime = xfield.get('type') === 'date'; if (isDateTime) { x = moment(x).toDate(); } var yfield = self.model.fields.get(field); var y = doc.getFieldValue(yfield); if (typeof x === 'string') { x = parseFloat(x); if (isNaN(x)) { x = index; } } // horizontal bar chart if (self.state.attributes.graphType == 'bars') { points.push([y, x]); } else { points.push([x, y]); } }); series.push({data: points, label: field, mouse:{lineColor: self.graphColors[series.length]}}); }); return series; } }); my.GraphControls = Backbone.View.extend({ className: "editor", template: ' \
\
\
\ \
\ \
\ \
\ \
\
\
\
\
\ \
\ \
\
\ ', templateSeriesEditor: ' \
\ \
\ \
\
\ ', events: { 'change form select': 'onEditorSubmit', 'click .editor-add': '_onAddSeries', 'click .action-remove-series': 'removeSeries' }, initialize: function(options) { var self = this; this.el = $(this.el); _.bindAll(this, 'render'); this.model.fields.bind('reset', this.render); this.model.fields.bind('add', this.render); this.state = new recline.Model.ObjectState(options.state); this.render(); }, render: function() { var self = this; var tmplData = this.model.toTemplateJSON(); var htmls = Mustache.render(this.template, tmplData); this.el.html(htmls); // set up editor from state if (this.state.get('graphType')) { this._selectOption('.editor-type', this.state.get('graphType')); } if (this.state.get('group')) { this._selectOption('.editor-group', this.state.get('group')); } // ensure at least one series box shows up var tmpSeries = [""]; if (this.state.get('series').length > 0) { tmpSeries = this.state.get('series'); } _.each(tmpSeries, function(series, idx) { self.addSeries(idx); self._selectOption('.editor-series.js-series-' + idx, series); }); return this; }, // Private: Helper function to select an option from a select list // _selectOption: function(id,value){ var options = this.el.find(id + ' select > option'); if (options) { options.each(function(opt){ if (this.value == value) { $(this).attr('selected','selected'); return false; } }); } }, onEditorSubmit: function(e) { var select = this.el.find('.editor-group select'); var $editor = this; var $series = this.el.find('.editor-series select'); var series = $series.map(function () { return $(this).val(); }); var updatedState = { series: $.makeArray(series), group: this.el.find('.editor-group select').val(), graphType: this.el.find('.editor-type select').val() }; this.state.set(updatedState); }, // Public: Adds a new empty series select box to the editor. // // @param [int] idx index of this series in the list of series // // Returns itself. addSeries: function (idx) { var data = _.extend({ seriesIndex: idx, seriesName: String.fromCharCode(idx + 64 + 1), }, this.model.toTemplateJSON()); var htmls = Mustache.render(this.templateSeriesEditor, data); this.el.find('.editor-series-group').append(htmls); return this; }, _onAddSeries: function(e) { e.preventDefault(); this.addSeries(this.state.get('series').length); }, // Public: Removes a series list item from the editor. // // Also updates the labels of the remaining series elements. removeSeries: function (e) { e.preventDefault(); var $el = $(e.target); $el.parent().parent().remove(); this.onEditorSubmit(); } }); })(jQuery, recline.View);