diff --git a/_includes/recline-deps.html b/_includes/recline-deps.html index 87705ccc..7b78093b 100644 --- a/_includes/recline-deps.html +++ b/_includes/recline-deps.html @@ -13,7 +13,6 @@ - @@ -26,7 +25,6 @@ - @@ -65,8 +63,8 @@ - + diff --git a/css/graph.css b/css/flotr2.css similarity index 100% rename from css/graph.css rename to css/flotr2.css diff --git a/src/view.flotr2.js b/src/view.flotr2.js new file mode 100644 index 00000000..9259e046 --- /dev/null +++ b/src/view.flotr2.js @@ -0,0 +1,462 @@ +/*jshint multistr:true */ + +this.recline = this.recline || {}; +this.recline.View = this.recline.View || {}; + +(function($, my) { + +// ## Graph view for a Dataset using Flotr2 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', +// graphOptions: {custom [Flotr2 options](http://www.humblesoftware.com/flotr2/documentation#configuration)} +// } +// +// 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.Flotr2 = Backbone.View.extend({ + 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); + 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.Flotr2Controls({ + 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; + }, + + 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 Flotr2 graph if either: + + // * The relevant div that graph attaches to his hidden at the moment of creating the plot -- Flotr2 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); + } + }, + + show: function() { + // because we cannot redraw when hidden we may need to when becoming visible + if (this.needToRedraw) { + this.redraw(); + } + }, + + // ### getGraphOptions + // + // Get options for Flotr2 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; + + var tickFormatter = function (x) { + return getFormattedX(x); + }; + + // infoboxes on mouse hover on points/bars etc + 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; + } + + x = getFormattedX(x); + + var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', { + group: self.state.attributes.group, + x: x, + series: obj.series.label, + y: y + }); + + return content; + }; + + var getFormattedX = function (x) { + var xfield = self.model.fields.get(self.state.attributes.group); + + // time series + var xtype = xfield.get('type'); + var isDateTime = (xtype === 'date' || xtype === 'date-time' || xtype === 'time'); + + if (self.model.records.models[parseInt(x)]) { + x = self.model.records.models[parseInt(x)].get(self.state.attributes.group); + if (isDateTime) { + x = new Date(x).toLocaleDateString(); + } + } else if (isDateTime) { + x = new Date(parseInt(x)).toLocaleDateString(); + } + return x; + } + + var xaxis = {}; + xaxis.tickFormatter = tickFormatter; + + 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 }, + xaxis: yaxis, + yaxis: xaxis, + 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 }, + xaxis: xaxis, + 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 } + }; + + if (self.state.get('graphOptions')){ + return _.extend(optionsPerGraphType[typeId], + self.state.get('graphOptions') + ) + }else{ + 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 xtype = xfield.get('type'); + var isDateTime = (xtype === 'date' || xtype === 'date-time' || xtype === 'time'); + + if (isDateTime) { + // datetime + if (self.state.attributes.graphType != 'bars' && self.state.attributes.graphType != 'columns') { + // not bar or column + x = new Date(x).getTime(); + } else { + // bar or column + x = index; + } + } else if (typeof x === 'string') { + // string + x = parseFloat(x); + if (isNaN(x)) { + x = index; + } + } + + var yfield = self.model.fields.get(field); + var y = doc.getFieldValue(yfield); + + // 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.Flotr2Controls = 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); + diff --git a/src/view.graph.js b/src/view.graph.js index dc640ca0..0517b2f8 100644 --- a/src/view.graph.js +++ b/src/view.graph.js @@ -1,462 +1,5 @@ -/*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', -// graphOptions: {custom [Flotr2 options](http://www.humblesoftware.com/flotr2/documentation#configuration)} -// } -// -// 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({ - 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); - 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; - }, - - 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); - } - }, - - show: function() { - // because we cannot redraw when hidden we may need to when becoming visible - if (this.needToRedraw) { - this.redraw(); - } - }, - - // ### 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; - - var tickFormatter = function (x) { - return getFormattedX(x); - }; - - // infoboxes on mouse hover on points/bars etc - 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; - } - - x = getFormattedX(x); - - var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', { - group: self.state.attributes.group, - x: x, - series: obj.series.label, - y: y - }); - - return content; - }; - - var getFormattedX = function (x) { - var xfield = self.model.fields.get(self.state.attributes.group); - - // time series - var xtype = xfield.get('type'); - var isDateTime = (xtype === 'date' || xtype === 'date-time' || xtype === 'time'); - - if (self.model.records.models[parseInt(x)]) { - x = self.model.records.models[parseInt(x)].get(self.state.attributes.group); - if (isDateTime) { - x = new Date(x).toLocaleDateString(); - } - } else if (isDateTime) { - x = new Date(parseInt(x)).toLocaleDateString(); - } - return x; - } - - var xaxis = {}; - xaxis.tickFormatter = tickFormatter; - - 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 }, - xaxis: yaxis, - yaxis: xaxis, - 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 }, - xaxis: xaxis, - 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 } - }; - - if (self.state.get('graphOptions')){ - return _.extend(optionsPerGraphType[typeId], - self.state.get('graphOptions') - ) - }else{ - 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 xtype = xfield.get('type'); - var isDateTime = (xtype === 'date' || xtype === 'date-time' || xtype === 'time'); - - if (isDateTime) { - // datetime - if (self.state.attributes.graphType != 'bars' && self.state.attributes.graphType != 'columns') { - // not bar or column - x = new Date(x).getTime(); - } else { - // bar or column - x = index; - } - } else if (typeof x === 'string') { - // string - x = parseFloat(x); - if (isNaN(x)) { - x = index; - } - } - - var yfield = self.model.fields.get(field); - var y = doc.getFieldValue(yfield); - - // 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); +this.recline.View.Graph = this.recline.View.Flot; +this.recline.View.GraphControls = this.recline.View.FlotControls; diff --git a/test/index.html b/test/index.html index e9152613..0bf98a62 100644 --- a/test/index.html +++ b/test/index.html @@ -53,8 +53,9 @@ - + + @@ -65,7 +66,7 @@ - + diff --git a/test/view.graph.test.js b/test/view.flotr2.test.js similarity index 87% rename from test/view.graph.test.js rename to test/view.flotr2.test.js index 25a04d6c..89edfada 100644 --- a/test/view.graph.test.js +++ b/test/view.flotr2.test.js @@ -1,8 +1,8 @@ -module("View - Graph"); +module("View - Flotr2"); test('basics', function () { var dataset = Fixture.getDataset(); - var view = new recline.View.Graph({ + var view = new recline.View.Flotr2({ model: dataset }); $('.fixtures').append(view.el); @@ -14,7 +14,7 @@ test('basics', function () { test('initialize', function () { var dataset = Fixture.getDataset(); - var view = new recline.View.Graph({ + var view = new recline.View.Flotr2({ model: dataset, state: { 'graphType': 'lines', @@ -40,7 +40,7 @@ test('initialize', function () { test('dates in graph view', function () { expect(0); var dataset = Fixture.getDataset(); - var view = new recline.View.Graph({ + var view = new recline.View.Flotr2({ model: dataset, state: { 'graphType': 'lines', @@ -53,9 +53,9 @@ test('dates in graph view', function () { view.remove(); }); -test('GraphControls basics', function () { +test('Flotr2Controls basics', function () { var dataset = Fixture.getDataset(); - var view = new recline.View.GraphControls({ + var view = new recline.View.Flotr2Controls({ model: dataset, state: { graphType: 'bars', @@ -72,7 +72,7 @@ test('GraphControls basics', function () { test('Overriding graph options', function () { var dataset = Fixture.getDataset(); var randomWidth = Math.random(); - var view = new recline.View.Graph({ + var view = new recline.View.Flotr2({ model: dataset, state: { 'graphType': 'bars', @@ -83,4 +83,4 @@ test('Overriding graph options', function () { }); equal(view.getGraphOptions('bars').bars.barWidth, randomWidth) view.remove(); -}); \ No newline at end of file +});