diff --git a/README.md b/README.md index e6260030..d9e1d58f 100755 --- a/README.md +++ b/README.md @@ -15,7 +15,17 @@ A simple but powerful library for building data applications in pure Javascript ## Developer Notes -Running the tests by opening `test/index.html` in your browser. +Run the tests by opening `test/index.html` in your browser. + +Note that the demos and documentation utilize the [jekyll templating +system][jekyll] and to use them *locally* you will need to build them using +jekyll. Once installed, all you need to do from the command line is run jekyll: + + jekyll + +[jekyll]: https://github.com/mojombo/jekyll + +Notes on the architecture can be found in the [documentation online](http://okfnlabs.org/recline). ### Contributing @@ -43,6 +53,16 @@ For larger changes: * Update `/_includes/recline-deps.html` if you change required files (e.g. leaflet libraries) * Try to build the demos in `/demos/` with jekyll and then check out the `/demos/multiview/` which utilizes most aspects of Recline +### Contributors + +* [Rufus Pollock](http://rufuspollock.org/) +* [Max Ogden](http://maxogden.com/) +* [John Glover](https://github.com/johnglover) +* [James Casbon](http://casbon.me/) +* [AdriĆ  Mercader](http://amercader.net/) +* [Dominik Moritz](https://github.com/domoritz) +* [Friedrich Lindenberg](http://pudo.org/) +* And [many more](https://github.com/okfn/recline/graphs/contributors) ## Changelog @@ -56,6 +76,8 @@ Possible breaking changes * Added marker clustering in map view to handle a large number of markers * Dataset.restore method removed (not used internally except from Multiview.restore) * Views no longer call render in initialize but must be called client code +* Backend.Memory.Store attribute for holding 'records' renamed to `records` from `data` +* Require new underscore.deferred vendor library for all use (jQuery no longer required if just using recline.dataset.js) ### v0.5 - July 5th 2012 (first public release) diff --git a/_includes/recline-deps.html b/_includes/recline-deps.html index af8baeeb..84d43041 100644 --- a/_includes/recline-deps.html +++ b/_includes/recline-deps.html @@ -14,6 +14,7 @@ + @@ -22,10 +23,15 @@ + + + @@ -61,6 +67,7 @@ + diff --git a/css/flot.css b/css/flot.css new file mode 100644 index 00000000..03f21e56 --- /dev/null +++ b/css/flot.css @@ -0,0 +1,26 @@ +.recline-graph .graph { + height: 500px; + overflow: hidden; +} + +.recline-graph .legend table { + width: auto; + margin-bottom: 0; +} + +.recline-graph .legend td { + padding: 5px; + line-height: 13px; +} + +.recline-graph .graph .alert { + width: 450px; +} + +#recline-graph-tooltip { + position: absolute; + background-color: #FEE !important; + color: #000000 !important; + opacity: 0.8 !important; + border: 1px solid #fdd !important; +} diff --git a/dist/recline.css b/dist/recline.css index 6a7f23d7..610a56a7 100644 --- a/dist/recline.css +++ b/dist/recline.css @@ -1,3 +1,29 @@ +.recline-graph .graph { + height: 500px; + overflow: hidden; +} + +.recline-graph .legend table { + width: auto; + margin-bottom: 0; +} + +.recline-graph .legend td { + padding: 5px; + line-height: 13px; +} + +.recline-graph .graph .alert { + width: 450px; +} + +#recline-graph-tooltip { + position: absolute; + background-color: #FEE !important; + color: #000000 !important; + opacity: 0.8 !important; + border: 1px solid #fdd !important; +} .recline-graph .graph { height: 500px; } diff --git a/dist/recline.dataset.js b/dist/recline.dataset.js index 963cac53..23fce729 100644 --- a/dist/recline.dataset.js +++ b/dist/recline.dataset.js @@ -2,7 +2,9 @@ this.recline = this.recline || {}; this.recline.Model = this.recline.Model || {}; -(function($, my) { +(function(my) { + +var Deferred = _.isUndefined(this.jQuery) ? _.Deferred : jQuery.Deferred; // ## Dataset my.Dataset = Backbone.Model.extend({ @@ -47,7 +49,7 @@ my.Dataset = Backbone.Model.extend({ // Retrieve dataset and (some) records from the backend. fetch: function() { var self = this; - var dfd = $.Deferred(); + var dfd = new Deferred(); if (this.backend !== recline.Backend.Memory) { this.backend.fetch(this.toJSON()) @@ -181,7 +183,7 @@ my.Dataset = Backbone.Model.extend({ // also returned. query: function(queryObj) { var self = this; - var dfd = $.Deferred(); + var dfd = new Deferred(); this.trigger('query:start'); if (queryObj) { @@ -245,7 +247,7 @@ my.Dataset = Backbone.Model.extend({ this.fields.each(function(field) { query.addFacet(field.id); }); - var dfd = $.Deferred(); + var dfd = new Deferred(); this._store.query(query.toJSON(), this.toJSON()).done(function(queryResult) { if (queryResult.facets) { _.each(queryResult.facets, function(facetResult, facetId) { @@ -585,13 +587,13 @@ Backbone.sync = function(method, model, options) { return model.backend.sync(method, model, options); }; -}(jQuery, this.recline.Model)); +}(this.recline.Model)); this.recline = this.recline || {}; this.recline.Backend = this.recline.Backend || {}; this.recline.Backend.Memory = this.recline.Backend.Memory || {}; -(function($, my) { +(function(my) { my.__type__ = 'memory'; // ## Data Wrapper @@ -600,42 +602,44 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {}; // functionality like querying, faceting, updating (by ID) and deleting (by // ID). // - // @param data list of hashes for each record/row in the data ({key: + // @param records list of hashes for each record/row in the data ({key: // value, key: value}) // @param fields (optional) list of field hashes (each hash defining a field // as per recline.Model.Field). If fields not specified they will be taken // from the data. - my.Store = function(data, fields) { + my.Store = function(records, fields) { var self = this; - this.data = data; + this.records = records; + // backwards compatability (in v0.5 records was named data) + this.data = this.records; if (fields) { this.fields = fields; } else { - if (data) { - this.fields = _.map(data[0], function(value, key) { + if (records) { + this.fields = _.map(records[0], function(value, key) { return {id: key, type: 'string'}; }); } } this.update = function(doc) { - _.each(self.data, function(internalDoc, idx) { + _.each(self.records, function(internalDoc, idx) { if(doc.id === internalDoc.id) { - self.data[idx] = doc; + self.records[idx] = doc; } }); }; this.remove = function(doc) { - var newdocs = _.reject(self.data, function(internalDoc) { + var newdocs = _.reject(self.records, function(internalDoc) { return (doc.id === internalDoc.id); }); - this.data = newdocs; + this.records = newdocs; }; this.save = function(changes, dataset) { var self = this; - var dfd = $.Deferred(); + var dfd = new _.Deferred(); // TODO _.each(changes.creates) { ... } _.each(changes.updates, function(record) { self.update(record); @@ -648,10 +652,10 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {}; }, this.query = function(queryObj) { - var dfd = $.Deferred(); - var numRows = queryObj.size || this.data.length; + var dfd = new _.Deferred(); + var numRows = queryObj.size || this.records.length; var start = queryObj.from || 0; - var results = this.data; + var results = this.records; results = this._applyFilters(results, queryObj); results = this._applyFreeTextQuery(results, queryObj); @@ -816,11 +820,11 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {}; }; this.transform = function(editFunc) { - var dfd = $.Deferred(); + var dfd = new _.Deferred(); // TODO: should we clone before mapping? Do not see the point atm. - self.data = _.map(self.data, editFunc); + self.records = _.map(self.records, editFunc); // now deal with deletes (i.e. nulls) - self.data = _.filter(self.data, function(record) { + self.records = _.filter(self.records, function(record) { return record != null; }); dfd.resolve(); @@ -828,4 +832,4 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {}; }; }; -}(jQuery, this.recline.Backend.Memory)); +}(this.recline.Backend.Memory)); diff --git a/dist/recline.js b/dist/recline.js index 3f0591ba..192b861b 100644 --- a/dist/recline.js +++ b/dist/recline.js @@ -2,7 +2,7 @@ this.recline = this.recline || {}; this.recline.Backend = this.recline.Backend || {}; this.recline.Backend.Ckan = this.recline.Backend.Ckan || {}; -(function($, my) { +(function(my) { // ## CKAN Backend // // This provides connection to the CKAN DataStore (v2) @@ -41,7 +41,7 @@ this.recline.Backend.Ckan = this.recline.Backend.Ckan || {}; dataset.id = out.resource_id; var wrapper = my.DataStore(out.endpoint); } - var dfd = $.Deferred(); + var dfd = new _.Deferred(); var jqxhr = wrapper.search({resource_id: dataset.id, limit: 0}); jqxhr.done(function(results) { // map ckan types to our usual types ... @@ -84,7 +84,7 @@ this.recline.Backend.Ckan = this.recline.Backend.Ckan || {}; var wrapper = my.DataStore(out.endpoint); } var actualQuery = my._normalizeQuery(queryObj, dataset); - var dfd = $.Deferred(); + var dfd = new _.Deferred(); var jqxhr = wrapper.search(actualQuery); jqxhr.done(function(results) { var out = { @@ -107,7 +107,7 @@ this.recline.Backend.Ckan = this.recline.Backend.Ckan || {}; }; that.search = function(data) { var searchUrl = that.endpoint + '/3/action/datastore_search'; - var jqxhr = $.ajax({ + var jqxhr = jQuery.ajax({ url: searchUrl, data: data, dataType: 'json' @@ -136,13 +136,14 @@ this.recline.Backend.Ckan = this.recline.Backend.Ckan || {}; 'float8': 'float' }; -}(jQuery, this.recline.Backend.Ckan)); +}(this.recline.Backend.Ckan)); this.recline = this.recline || {}; this.recline.Backend = this.recline.Backend || {}; this.recline.Backend.CSV = this.recline.Backend.CSV || {}; // Note that provision of jQuery is optional (it is **only** needed if you use fetch on a remote file) -(function(my, $) { +(function(my) { + my.__type__ = 'csv'; // ## fetch // @@ -150,7 +151,7 @@ this.recline.Backend.CSV = this.recline.Backend.CSV || {}; // // 1. `dataset.file`: `file` is an HTML5 file object. This is opened and parsed with the CSV parser. // 2. `dataset.data`: `data` is a string in CSV format. This is passed directly to the CSV parser - // 3. `dataset.url`: a url to an online CSV file that is ajax accessible (note this usually requires either local or on a server that is CORS enabled). The file is then loaded using $.ajax and parsed using the CSV parser (NB: this requires jQuery) + // 3. `dataset.url`: a url to an online CSV file that is ajax accessible (note this usually requires either local or on a server that is CORS enabled). The file is then loaded using jQuery.ajax and parsed using the CSV parser (NB: this requires jQuery) // // All options generates similar data and use the memory store outcome, that is they return something like: // @@ -162,7 +163,7 @@ this.recline.Backend.CSV = this.recline.Backend.CSV || {}; // } // my.fetch = function(dataset) { - var dfd = $.Deferred(); + var dfd = new _.Deferred(); if (dataset.file) { var reader = new FileReader(); var encoding = dataset.encoding || 'UTF-8'; @@ -187,7 +188,7 @@ this.recline.Backend.CSV = this.recline.Backend.CSV || {}; useMemoryStore: true }); } else if (dataset.url) { - $.get(dataset.url).done(function(data) { + jQuery.get(dataset.url).done(function(data) { var rows = my.parseCSV(data, dataset); dfd.resolve({ records: rows, @@ -424,12 +425,12 @@ this.recline.Backend.CSV = this.recline.Backend.CSV || {}; } -}(this.recline.Backend.CSV, jQuery)); +}(this.recline.Backend.CSV)); this.recline = this.recline || {}; this.recline.Backend = this.recline.Backend || {}; this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {}; -(function($, my) { +(function(my) { my.__type__ = 'dataproxy'; // URL for the dataproxy my.dataproxy_url = 'http://jsonpdataproxy.appspot.com'; @@ -448,12 +449,12 @@ this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {}; 'max-results': dataset.size || dataset.rows || 1000, type: dataset.format || '' }; - var jqxhr = $.ajax({ + var jqxhr = jQuery.ajax({ url: my.dataproxy_url, data: data, dataType: 'jsonp' }); - var dfd = $.Deferred(); + var dfd = new _.Deferred(); _wrapInTimeout(jqxhr).done(function(results) { if (results.error) { dfd.reject(results.error); @@ -477,7 +478,7 @@ this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {}; // Many of backends use JSONP and so will not get error messages and this is // a crude way to catch those errors. var _wrapInTimeout = function(ourFunction) { - var dfd = $.Deferred(); + var dfd = new _.Deferred(); var timer = setTimeout(function() { dfd.reject({ message: 'Request Error: Backend did not respond after ' + (my.timeout / 1000) + ' seconds' @@ -495,7 +496,7 @@ this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {}; return dfd.promise(); } -}(jQuery, this.recline.Backend.DataProxy)); +}(this.recline.Backend.DataProxy)); this.recline = this.recline || {}; this.recline.Backend = this.recline.Backend || {}; this.recline.Backend.ElasticSearch = this.recline.Backend.ElasticSearch || {}; @@ -677,7 +678,7 @@ this.recline.Backend.ElasticSearch = this.recline.Backend.ElasticSearch || {}; // ### fetch my.fetch = function(dataset) { var es = new my.Wrapper(dataset.url, my.esOptions); - var dfd = $.Deferred(); + var dfd = new _.Deferred(); es.mapping().done(function(schema) { if (!schema){ @@ -705,7 +706,7 @@ this.recline.Backend.ElasticSearch = this.recline.Backend.ElasticSearch || {}; my.save = function(changes, dataset) { var es = new my.Wrapper(dataset.url, my.esOptions); if (changes.creates.length + changes.updates.length + changes.deletes.length > 1) { - var dfd = $.Deferred(); + var dfd = new _.Deferred(); msg = 'Saving more than one item at a time not yet supported'; alert(msg); dfd.reject(msg); @@ -723,7 +724,7 @@ this.recline.Backend.ElasticSearch = this.recline.Backend.ElasticSearch || {}; // ### query my.query = function(queryObj, dataset) { - var dfd = $.Deferred(); + var dfd = new _.Deferred(); var es = new my.Wrapper(dataset.url, my.esOptions); var jqxhr = es.query(queryObj); jqxhr.done(function(results) { @@ -782,7 +783,7 @@ this.recline = this.recline || {}; this.recline.Backend = this.recline.Backend || {}; this.recline.Backend.GDocs = this.recline.Backend.GDocs || {}; -(function($, my) { +(function(my) { my.__type__ = 'gdocs'; // ## Google spreadsheet backend @@ -809,15 +810,15 @@ this.recline.Backend.GDocs = this.recline.Backend.GDocs || {}; // * fields: array of Field objects // * records: array of objects for each row my.fetch = function(dataset) { - var dfd = $.Deferred(); + var dfd = new _.Deferred(); var urls = my.getGDocsAPIUrls(dataset.url); // TODO cover it with tests // get the spreadsheet title (function () { - var titleDfd = $.Deferred(); + var titleDfd = new _.Deferred(); - $.getJSON(urls.spreadsheet, function (d) { + jQuery.getJSON(urls.spreadsheet, function (d) { titleDfd.resolve({ spreadsheetTitle: d.feed.title.$t }); @@ -827,7 +828,7 @@ this.recline.Backend.GDocs = this.recline.Backend.GDocs || {}; }()).then(function (response) { // get the actual worksheet data - $.getJSON(urls.worksheet, function(d) { + jQuery.getJSON(urls.worksheet, function(d) { var result = my.parseData(d); var fields = _.map(result.fields, function(fieldId) { return {id: fieldId}; @@ -941,12 +942,12 @@ this.recline.Backend.GDocs = this.recline.Backend.GDocs || {}; return urls; }; -}(jQuery, this.recline.Backend.GDocs)); +}(this.recline.Backend.GDocs)); this.recline = this.recline || {}; this.recline.Backend = this.recline.Backend || {}; this.recline.Backend.Memory = this.recline.Backend.Memory || {}; -(function($, my) { +(function(my) { my.__type__ = 'memory'; // ## Data Wrapper @@ -955,42 +956,44 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {}; // functionality like querying, faceting, updating (by ID) and deleting (by // ID). // - // @param data list of hashes for each record/row in the data ({key: + // @param records list of hashes for each record/row in the data ({key: // value, key: value}) // @param fields (optional) list of field hashes (each hash defining a field // as per recline.Model.Field). If fields not specified they will be taken // from the data. - my.Store = function(data, fields) { + my.Store = function(records, fields) { var self = this; - this.data = data; + this.records = records; + // backwards compatability (in v0.5 records was named data) + this.data = this.records; if (fields) { this.fields = fields; } else { - if (data) { - this.fields = _.map(data[0], function(value, key) { + if (records) { + this.fields = _.map(records[0], function(value, key) { return {id: key, type: 'string'}; }); } } this.update = function(doc) { - _.each(self.data, function(internalDoc, idx) { + _.each(self.records, function(internalDoc, idx) { if(doc.id === internalDoc.id) { - self.data[idx] = doc; + self.records[idx] = doc; } }); }; this.remove = function(doc) { - var newdocs = _.reject(self.data, function(internalDoc) { + var newdocs = _.reject(self.records, function(internalDoc) { return (doc.id === internalDoc.id); }); - this.data = newdocs; + this.records = newdocs; }; this.save = function(changes, dataset) { var self = this; - var dfd = $.Deferred(); + var dfd = new _.Deferred(); // TODO _.each(changes.creates) { ... } _.each(changes.updates, function(record) { self.update(record); @@ -1003,10 +1006,10 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {}; }, this.query = function(queryObj) { - var dfd = $.Deferred(); - var numRows = queryObj.size || this.data.length; + var dfd = new _.Deferred(); + var numRows = queryObj.size || this.records.length; var start = queryObj.from || 0; - var results = this.data; + var results = this.records; results = this._applyFilters(results, queryObj); results = this._applyFreeTextQuery(results, queryObj); @@ -1171,11 +1174,11 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {}; }; this.transform = function(editFunc) { - var dfd = $.Deferred(); + var dfd = new _.Deferred(); // TODO: should we clone before mapping? Do not see the point atm. - self.data = _.map(self.data, editFunc); + self.records = _.map(self.records, editFunc); // now deal with deletes (i.e. nulls) - self.data = _.filter(self.data, function(record) { + self.records = _.filter(self.records, function(record) { return record != null; }); dfd.resolve(); @@ -1183,7 +1186,7 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {}; }; }; -}(jQuery, this.recline.Backend.Memory)); +}(this.recline.Backend.Memory)); this.recline = this.recline || {}; this.recline.Backend = this.recline.Backend || {}; this.recline.Backend.Solr = this.recline.Backend.Solr || {}; @@ -1204,7 +1207,7 @@ this.recline.Backend.Solr = this.recline.Backend.Solr || {}; dataType: 'jsonp', jsonp: 'json.wrf' }); - var dfd = $.Deferred(); + var dfd = new _.Deferred(); jqxhr.done(function(results) { // if we get 0 results we cannot get fields var fields = [] @@ -1237,7 +1240,7 @@ this.recline.Backend.Solr = this.recline.Backend.Solr || {}; dataType: 'jsonp', jsonp: 'json.wrf' }); - var dfd = $.Deferred(); + var dfd = new _.Deferred(); jqxhr.done(function(results) { var out = { total: results.response.numFound, @@ -1386,7 +1389,9 @@ if (!('some' in Array.prototype)) { this.recline = this.recline || {}; this.recline.Model = this.recline.Model || {}; -(function($, my) { +(function(my) { + +var Deferred = _.isUndefined(this.jQuery) ? _.Deferred : jQuery.Deferred; // ## Dataset my.Dataset = Backbone.Model.extend({ @@ -1431,7 +1436,7 @@ my.Dataset = Backbone.Model.extend({ // Retrieve dataset and (some) records from the backend. fetch: function() { var self = this; - var dfd = $.Deferred(); + var dfd = new Deferred(); if (this.backend !== recline.Backend.Memory) { this.backend.fetch(this.toJSON()) @@ -1565,7 +1570,7 @@ my.Dataset = Backbone.Model.extend({ // also returned. query: function(queryObj) { var self = this; - var dfd = $.Deferred(); + var dfd = new Deferred(); this.trigger('query:start'); if (queryObj) { @@ -1629,7 +1634,7 @@ my.Dataset = Backbone.Model.extend({ this.fields.each(function(field) { query.addFacet(field.id); }); - var dfd = $.Deferred(); + var dfd = new Deferred(); this._store.query(query.toJSON(), this.toJSON()).done(function(queryResult) { if (queryResult.facets) { _.each(queryResult.facets, function(facetResult, facetId) { @@ -1969,7 +1974,7 @@ Backbone.sync = function(method, model, options) { return model.backend.sync(method, model, options); }; -}(jQuery, this.recline.Model)); +}(this.recline.Model)); /*jshint multistr:true */ @@ -1978,6 +1983,508 @@ 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 [flot options]} +// } +// +// 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.Flot = 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', '_toolTip', '_xaxisLabel'); + 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.previousTooltipPoint = {x: null, y: null}; + this.editor = new my.FlotControls({ + 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'); + this.$graph.on("plothover", this._toolTip); + return this; + }, + + redraw: function() { + // There are 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() - 240); + var series = this.createSeries(); + var options = this.getGraphOptions(this.state.attributes.graphType, series[0].data.length); + this.plot = $.plot(this.$graph, series, options); + } + }, + + show: function() { + // because we cannot redraw when hidden we may need to when becoming visible + if (this.needToRedraw) { + this.redraw(); + } + }, + + // infoboxes on mouse hover on points/bars etc + _toolTip: function (event, pos, item) { + if (item) { + if (this.previousTooltipPoint.x !== item.dataIndex || + this.previousTooltipPoint.y !== item.seriesIndex) { + this.previousTooltipPoint.x = item.dataIndex; + this.previousTooltipPoint.y = item.seriesIndex; + $("#recline-graph-tooltip").remove(); + + var x = item.datapoint[0].toFixed(2), + y = item.datapoint[1].toFixed(2); + + var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', { + group: this.state.attributes.group, + x: this._xaxisLabel(x), + series: item.series.label, + y: y + }); + + // use a different tooltip location offset for bar charts + var xLocation, yLocation; + if (this.state.attributes.graphType === 'bars') { + xLocation = item.pageX + 15; + yLocation = item.pageY; + } else { + xLocation = item.pageX + 10; + yLocation = item.pageY - 20; + } + + $('
' + content + '
').css({ + top: yLocation, + left: xLocation + }).appendTo("body").fadeIn(200); + } + } else { + $("#recline-graph-tooltip").remove(); + this.previousTooltipPoint.x = null; + this.previousTooltipPoint.y = null; + } + }, + + _xaxisLabel: function (x) { + var xfield = this.model.fields.get(this.state.attributes.group); + + // time series + var xtype = xfield.get('type'); + var isDateTime = (xtype === 'date' || xtype === 'date-time' || xtype === 'time'); + + if (this.model.records.models[parseInt(x, 10)]) { + x = this.model.records.models[parseInt(x, 10)].get(this.state.attributes.group); + if (isDateTime) { + x = new Date(x).toLocaleDateString(); + } + } else if (isDateTime) { + x = new Date(parseInt(x, 10)).toLocaleDateString(); + } + + return x; + }, + + // ### getGraphOptions + // + // Get options for Flot Graph + // + // needs to be function as can depend on state + // + // @param typeId graphType id (lines, lines-and-points etc) + // @param numPoints the number of points that will be plotted + getGraphOptions: function(typeId, numPoints) { + var self = this; + + var tickFormatter = function (x) { + // convert x to a string and make sure that it is not too long or the + // tick labels will overlap + // TODO: find a more accurate way of calculating the size of tick labels + var label = self._xaxisLabel(x); + + if (typeof label !== 'string') { + label = label.toString(); + } + if (label.length > 8) { + label = label.slice(0, 5) + "..."; + } + + return label; + }; + + var xaxis = {}; + xaxis.tickFormatter = tickFormatter; + + // calculate the x-axis ticks + // + // the number of ticks should be a multiple of the number of points so that + // each tick lines up with a point + if (numPoints) { + var ticks = [], + maxTicks = 10, + x = 1, + i = 0; + + while (x <= maxTicks) { + if ((numPoints / x) <= maxTicks) { + break; + } + x = x + 1; + } + + for (i = 0; i < numPoints; i = i + x) { + ticks.push(i); + } + + xaxis.ticks = ticks; + } + + var yaxis = {}; + yaxis.autoscale = true; + yaxis.autoscaleMargin = 0.02; + + var legend = {}; + legend.position = 'ne'; + + var grid = {}; + grid.hoverable = true; + grid.clickable = true; + grid.borderColor = "#aaaaaa"; + grid.borderWidth = 1; + + var optionsPerGraphType = { + lines: { + legend: legend, + colors: this.graphColors, + lines: { show: true }, + xaxis: xaxis, + yaxis: yaxis, + grid: grid + }, + points: { + legend: legend, + colors: this.graphColors, + points: { show: true, hitRadius: 5 }, + xaxis: xaxis, + yaxis: yaxis, + grid: grid + }, + 'lines-and-points': { + legend: legend, + colors: this.graphColors, + points: { show: true, hitRadius: 5 }, + lines: { show: true }, + xaxis: xaxis, + yaxis: yaxis, + grid: grid + }, + bars: { + legend: legend, + colors: this.graphColors, + lines: { show: false }, + xaxis: yaxis, + yaxis: xaxis, + grid: grid, + bars: { + show: true, + horizontal: true, + shadowSize: 0, + align: 'center', + barWidth: 0.8 + } + }, + columns: { + legend: legend, + colors: this.graphColors, + lines: { show: false }, + xaxis: xaxis, + yaxis: yaxis, + grid: grid, + bars: { + show: true, + horizontal: false, + shadowSize: 0, + align: 'center', + barWidth: 0.8 + } + } + }; + + 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) { + if (self.state.attributes.graphType != 'bars' && + self.state.attributes.graphType != 'columns') { + x = new Date(x).getTime(); + } else { + x = index; + } + } else if (typeof x === 'string') { + x = parseFloat(x); + if (isNaN(x)) { + x = index; + } + } + + var yfield = self.model.fields.get(field); + var y = doc.getFieldValue(yfield); + + if (self.state.attributes.graphType == 'bars') { + points.push([y, x]); + } else { + points.push([x, y]); + } + }); + series.push({ + data: points, + label: field, + hoverable: true + }); + }); + return series; + } +}); + +my.FlotControls = 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); +/*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): @@ -2293,7 +2800,7 @@ my.GraphControls = Backbone.View.extend({ \ \ \ - \ + \
\ \ + \ + \ + \ + \ + \ + \ +
\ + \ +
\ + \ +
\ +
\ +
\ + \ +
\ + \ +
\ + \ + \ + \ +', + 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 d0371d69..dc640ca0 100644 --- a/src/view.graph.js +++ b/src/view.graph.js @@ -320,7 +320,7 @@ my.GraphControls = Backbone.View.extend({ \ \ \ - \ + \
\