Sharable Link to current View
diff --git a/app/js/app.js b/app/js/app.js
index 145ec05b..da882392 100755
--- a/app/js/app.js
+++ b/app/js/app.js
@@ -1,5 +1,5 @@
jQuery(function($) {
- var app = new ExplorerApp({
+ window.ReclineDataExplorer = new ExplorerApp({
el: $('.recline-app')
})
});
@@ -12,7 +12,7 @@ var ExplorerApp = Backbone.View.extend({
initialize: function() {
this.el = $(this.el);
- this.explorer = null;
+ this.dataExplorer = null;
this.explorerDiv = $('.data-explorer-here');
_.bindAll(this, 'viewExplorer', 'viewHome');
@@ -76,10 +76,36 @@ var ExplorerApp = Backbone.View.extend({
this.dataExplorer = null;
var $el = $('
');
$el.appendTo(this.explorerDiv);
+ var views = [
+ {
+ id: 'grid',
+ label: 'Grid',
+ view: new recline.View.SlickGrid({
+ model: dataset
+ })
+ },
+
+ {
+ id: 'graph',
+ label: 'Graph',
+ view: new recline.View.Graph({
+ model: dataset
+ })
+ },
+ {
+ id: 'map',
+ label: 'Map',
+ view: new recline.View.Map({
+ model: dataset
+ })
+ },
+ ];
+
this.dataExplorer = new recline.View.DataExplorer({
model: dataset,
el: $el,
- state: state
+ state: state,
+ views: views
});
this._setupPermaLink(this.dataExplorer);
this._setupEmbed(this.dataExplorer);
@@ -102,7 +128,7 @@ var ExplorerApp = Backbone.View.extend({
function makeEmbedLink(state) {
var link = self.makePermaLink(state);
link = link + '&embed=true';
- var out = $.mustache('
', {link: link});
+ var out = Mustache.render('
', {link: link});
return out;
}
explorer.state.bind('change', function() {
@@ -154,7 +180,7 @@ var ExplorerApp = Backbone.View.extend({
delimiter : $form.find('input[name="delimiter"]').val(),
encoding : $form.find('input[name="encoding"]').val()
};
- recline.Backend.loadFromCSVFile(file, function(dataset) {
+ recline.Backend.CSV.load(file, function(dataset) {
self.createExplorer(dataset)
},
options
diff --git a/app/style/demo.css b/app/style/demo.css
index 60b761ec..ad690766 100644
--- a/app/style/demo.css
+++ b/app/style/demo.css
@@ -11,6 +11,10 @@ body {
height: 550px;
}
+.recline-slickgrid {
+ height: 550px;
+}
+
.recline-timeline .vmm-timeline {
height: 550px;
}
diff --git a/css/graph.css b/css/graph.css
index 413ac14e..d88168c4 100644
--- a/css/graph.css
+++ b/css/graph.css
@@ -28,14 +28,6 @@
padding-left: 0px;
}
-.recline-graph .editor-info {
- padding-left: 4px;
-}
-
-.recline-graph .editor-info {
- cursor: pointer;
-}
-
.recline-graph .editor form {
padding-left: 4px;
}
@@ -44,11 +36,6 @@
width: 100%;
}
-.recline-graph .editor-info {
- border-bottom: 1px solid #ddd;
- margin-bottom: 10px;
-}
-
.recline-graph .editor-hide-info p {
display: none;
}
diff --git a/css/slickgrid.css b/css/slickgrid.css
new file mode 100644
index 00000000..8645ce42
--- /dev/null
+++ b/css/slickgrid.css
@@ -0,0 +1,166 @@
+/*
+IMPORTANT:
+In order to preserve the uniform grid appearance, all cell styles need to have padding, margin and border sizes.
+No built-in (selected, editable, highlight, flashing, invalid, loading, :focus) or user-specified CSS
+classes should alter those!
+*/
+
+.recline-slickgrid .slick-header-columns .slick-header-column {
+ background-color: #e6e6e6;
+ background-repeat: no-repeat;
+ background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6));
+ background-image: -webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+ background-image: -moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6);
+ background-image: -ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+ background-image: -o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+ background-image: linear-gradient(#ffffff, #ffffff 25%, #e6e6e6);
+ filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0);
+ text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75);
+ color: #333;
+ font-weight: bold;
+ border-right: 1px solid #ccc;
+ border-top: 1px solid #ccc;
+ border-bottom: 1px solid #bbb;
+ -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+ -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+
+.recline-slickgrid .slick-header-column:hover, .slick-header-column-active {
+}
+
+.recline-slickgrid .slick-headerrow {
+ background: #fafafa;
+}
+
+.recline-slickgrid .slick-headerrow-column {
+ background: #fafafa;
+ border-bottom: 0;
+ height: 100%;
+}
+
+.recline-slickgrid .slick-row.ui-state-active {
+ background: #F5F7D7;
+}
+
+.recline-slickgrid .slick-row {
+ position: absolute;
+ background: white;
+ border: 0px;
+ line-height: 20px;
+}
+
+.recline-slickgrid .slick-row.selected {
+ z-index: 10;
+ background: #DFE8F6;
+}
+
+.recline-slickgrid .slick-cell {
+ padding-left: 4px;
+ padding-right: 4px;
+}
+
+.recline-slickgrid .slick-group {
+ border-bottom: 2px solid silver;
+}
+
+.recline-slickgrid .slick-group-toggle {
+ width: 9px;
+ height: 9px;
+ margin-right: 5px;
+}
+
+.recline-slickgrid .slick-group-toggle.expanded {
+ background: url(../images/collapse.gif) no-repeat center center;
+}
+
+.recline-slickgrid .slick-group-toggle.collapsed {
+ background: url(../images/expand.gif) no-repeat center center;
+}
+
+.recline-slickgrid .slick-group-totals {
+ color: gray;
+ background: white;
+}
+
+.recline-slickgrid .slick-cell.selected {
+ background-color: beige;
+}
+
+.recline-slickgrid .slick-cell.active {
+ border-color: gray;
+ border-style: solid;
+}
+
+.recline-slickgrid .slick-sortable-placeholder {
+ background: silver !important;
+}
+
+.recline-slickgrid .slick-row[row$="1"], .slick-row[row$="3"], .slick-row[row$="5"], .slick-row[row$="7"], .slick-row[row$="9"] {
+ background: #fafafa;
+}
+
+.recline-slickgrid .slick-row.ui-state-active {
+ background: #F5F7D7;
+}
+
+.recline-slickgrid .slick-row.loading {
+ opacity: 0.5;
+ filter: alpha(opacity = 50);
+}
+
+.recline-slickgrid .slick-cell.invalid {
+ border-color: red;
+}
+
+.recline-slickgrid .slick-contextmenu {
+ border-radius: 5px
+}
+
+.recline-slickgrid .slick-contextmenu li {
+ clear: both;
+ height: 24px;
+ cursor: pointer;
+}
+
+.recline-slickgrid .slick-contextmenu .divider {
+ cursor: default;
+}
+
+.recline-slickgrid .slick-contextmenu > li:hover {
+ background-color: #0088cc;
+}
+
+.recline-slickgrid .slick-contextmenu .divider:hover {
+ background-color: #E5E5E5;
+}
+
+.recline-slickgrid .slick-contextmenu li:hover > label {
+ color: white;
+}
+
+.recline-slickgrid .slick-contextmenu input {
+ float: left;
+ margin-left: 15px;
+ margin-top: 5px;
+}
+
+.recline-slickgrid .slick-contextmenu label {
+ float: left;
+ margin-right: 15px;
+ margin-left: 5px;
+ margin-top: 3px;
+ color: #555;
+ cursor: pointer;
+}
+
+.recline-slickgrid .slick-row .slick-cell:first-child,
+.recline-slickgrid .slick-header {
+ border-left: 1px solid #ccc;
+}
+
+/* add one pixel extra as added one pixel to left border of header */
+.recline-slickgrid .slick-row .slick-cell {
+ margin-right: -1px;
+}
+
diff --git a/src/backend/base.js b/src/backend/base.js
index 94cccf4f..2758f51e 100644
--- a/src/backend/base.js
+++ b/src/backend/base.js
@@ -6,161 +6,108 @@
this.recline = this.recline || {};
this.recline.Backend = this.recline.Backend || {};
-(function($, my) {
- // ## Backbone.sync
+// ## recline.Backend.Base
+//
+// Exemplar 'class' for backends showing what a base class would look like.
+this.recline.Backend.Base = function() {
+ // ### __type__
//
- // Override Backbone.sync to hand off to sync function in relevant backend
- Backbone.sync = function(method, model, options) {
- return model.backend.sync(method, model, options);
- };
-
- // ## recline.Backend.Base
+ // 'type' of this backend. This should be either the class path for this
+ // object as a string (e.g. recline.Backend.Memory) or for Backends within
+ // recline.Backend module it may be their class name.
//
- // Base class for backends providing a template and convenience functions.
- // You do not have to inherit from this class but even when not it does
- // provide guidance on the functions you must implement.
+ // This value is used as an identifier for this backend when initializing
+ // backends (see recline.Model.Dataset.initialize).
+ this.__type__ = 'base';
+
+ // ### readonly
//
- // Note also that while this (and other Backends) are implemented as Backbone models this is just a convenience.
- my.Base = Backbone.Model.extend({
- // ### __type__
- //
- // 'type' of this backend. This should be either the class path for this
- // object as a string (e.g. recline.Backend.Memory) or for Backends within
- // recline.Backend module it may be their class name.
- //
- // This value is used as an identifier for this backend when initializing
- // backends (see recline.Model.Dataset.initialize).
- __type__: 'base',
+ // Class level attribute indicating that this backend is read-only (that
+ // is, cannot be written to).
+ this.readonly = true;
+ // ### sync
+ //
+ // An implementation of Backbone.sync that will be used to override
+ // Backbone.sync on operations for Datasets and Documents which are using this backend.
+ //
+ // For read-only implementations you will need only to implement read method
+ // for Dataset models (and even this can be a null operation). The read method
+ // should return relevant metadata for the Dataset. We do not require read support
+ // for Documents because they are loaded in bulk by the query method.
+ //
+ // For backends supporting write operations you must implement update and delete support for Document objects.
+ //
+ // All code paths should return an object conforming to the jquery promise API.
+ this.sync = function(method, model, options) {
+ },
+
+ // ### query
+ //
+ // Query the backend for documents returning them in bulk. This method will
+ // be used by the Dataset.query method to search the backend for documents,
+ // retrieving the results in bulk.
+ //
+ // @param {recline.model.Dataset} model: Dataset model.
+ //
+ // @param {Object} queryObj: object describing a query (usually produced by
+ // using recline.Model.Query and calling toJSON on it).
+ //
+ // The structure of data in the Query object or
+ // Hash should follow that defined in
issue 34.
+ // (Of course, if you are writing your own backend, and hence
+ // have control over the interpretation of the query object, you
+ // can use whatever structure you like).
+ //
+ // @returns {Promise} promise API object. The promise resolve method will
+ // be called on query completion with a QueryResult object.
+ //
+ // A QueryResult has the following structure (modelled closely on
+ // ElasticSearch - see
this issue for more
+ // details):
+ //
+ //
+ // {
+ // total: // (required) total number of results (can be null)
+ // hits: [ // (required) one entry for each result document
+ // {
+ // _score: // (optional) match score for document
+ // _type: // (optional) document type
+ // _source: // (required) document/row object
+ // }
+ // ],
+ // facets: { // (optional)
+ // // facet results (as per )
+ // }
+ // }
+ //
+ this.query = function(model, queryObj) {}
+};
- // ### readonly
- //
- // Class level attribute indicating that this backend is read-only (that
- // is, cannot be written to).
- readonly: true,
-
- // ### sync
- //
- // An implementation of Backbone.sync that will be used to override
- // Backbone.sync on operations for Datasets and Documents which are using this backend.
- //
- // For read-only implementations you will need only to implement read method
- // for Dataset models (and even this can be a null operation). The read method
- // should return relevant metadata for the Dataset. We do not require read support
- // for Documents because they are loaded in bulk by the query method.
- //
- // For backends supporting write operations you must implement update and delete support for Document objects.
- //
- // All code paths should return an object conforming to the jquery promise API.
- sync: function(method, model, options) {
- },
-
- // ### query
- //
- // Query the backend for documents returning them in bulk. This method will
- // be used by the Dataset.query method to search the backend for documents,
- // retrieving the results in bulk.
- //
- // @param {recline.model.Dataset} model: Dataset model.
- //
- // @param {Object} queryObj: object describing a query (usually produced by
- // using recline.Model.Query and calling toJSON on it).
- //
- // The structure of data in the Query object or
- // Hash should follow that defined in
issue 34.
- // (Of course, if you are writing your own backend, and hence
- // have control over the interpretation of the query object, you
- // can use whatever structure you like).
- //
- // @returns {Promise} promise API object. The promise resolve method will
- // be called on query completion with a QueryResult object.
- //
- // A QueryResult has the following structure (modelled closely on
- // ElasticSearch - see
this issue for more
- // details):
- //
- //
- // {
- // total: // (required) total number of results (can be null)
- // hits: [ // (required) one entry for each result document
- // {
- // _score: // (optional) match score for document
- // _type: // (optional) document type
- // _source: // (required) document/row object
- // }
- // ],
- // facets: { // (optional)
- // // facet results (as per )
- // }
- // }
- //
- query: function(model, queryObj) {
- },
-
- // ### _makeRequest
- //
- // Just $.ajax but in any headers in the 'headers' attribute of this
- // Backend instance. Example:
- //
- //
- // var jqxhr = this._makeRequest({
- // url: the-url
- // });
- //
- _makeRequest: function(data) {
- var headers = this.get('headers');
- var extras = {};
- if (headers) {
- extras = {
- beforeSend: function(req) {
- _.each(headers, function(value, key) {
- req.setRequestHeader(key, value);
- });
- }
- };
- }
- var data = _.extend(extras, data);
- return $.ajax(data);
- },
-
- // convenience method to convert simple set of documents / rows to a QueryResult
- _docsToQueryResult: function(rows) {
- var hits = _.map(rows, function(row) {
- return { _source: row };
- });
- return {
- total: null,
- hits: hits
- };
- },
-
- // ## _wrapInTimeout
- //
- // Convenience method providing a crude way to catch backend errors on JSONP calls.
- // Many of backends use JSONP and so will not get error messages and this is
- // a crude way to catch those errors.
- _wrapInTimeout: function(ourFunction) {
- var dfd = $.Deferred();
- var timeout = 5000;
- var timer = setTimeout(function() {
- dfd.reject({
- message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds'
+// ### makeRequest
+//
+// Just $.ajax but in any headers in the 'headers' attribute of this
+// Backend instance. Example:
+//
+//
+// var jqxhr = this._makeRequest({
+// url: the-url
+// });
+//
+this.recline.Backend.makeRequest = function(data, headers) {
+ var extras = {};
+ if (headers) {
+ extras = {
+ beforeSend: function(req) {
+ _.each(headers, function(value, key) {
+ req.setRequestHeader(key, value);
});
- }, timeout);
- ourFunction.done(function(arguments) {
- clearTimeout(timer);
- dfd.resolve(arguments);
- })
- .fail(function(arguments) {
- clearTimeout(timer);
- dfd.reject(arguments);
- })
- ;
- return dfd.promise();
- }
- });
-
-}(jQuery, this.recline.Backend));
+ }
+ };
+ }
+ var data = _.extend(extras, data);
+ return $.ajax(data);
+};
diff --git a/src/backend/localcsv.js b/src/backend/csv.js
similarity index 92%
rename from src/backend/localcsv.js
rename to src/backend/csv.js
index d1969fa2..a680ef17 100644
--- a/src/backend/localcsv.js
+++ b/src/backend/csv.js
@@ -1,8 +1,15 @@
this.recline = this.recline || {};
this.recline.Backend = this.recline.Backend || {};
+this.recline.Backend.CSV = this.recline.Backend.CSV || {};
-(function($, my) {
- my.loadFromCSVFile = function(file, callback, options) {
+(function(my) {
+ // ## load
+ //
+ // Load data from a CSV file referenced in an HTMl5 file object returning the
+ // dataset in the callback
+ //
+ // @param options as for parseCSV below
+ my.load = function(file, callback, options) {
var encoding = options.encoding || 'UTF-8';
var metadata = {
@@ -33,7 +40,7 @@ this.recline.Backend = this.recline.Backend || {};
});
return _doc;
});
- var dataset = recline.Backend.createDataset(data, fields);
+ var dataset = recline.Backend.Memory.createDataset(data, fields);
return dataset;
};
@@ -168,4 +175,4 @@ this.recline.Backend = this.recline.Backend || {};
}
-}(jQuery, this.recline.Backend));
+}(this.recline.Backend.CSV));
diff --git a/src/backend/dataproxy.js b/src/backend/dataproxy.js
index 16db3db6..a2731f00 100644
--- a/src/backend/dataproxy.js
+++ b/src/backend/dataproxy.js
@@ -1,12 +1,14 @@
this.recline = this.recline || {};
this.recline.Backend = this.recline.Backend || {};
+this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {};
(function($, my) {
// ## DataProxy Backend
//
// For connecting to [DataProxy-s](http://github.com/okfn/dataproxy).
//
- // When initializing the DataProxy backend you can set the following attributes:
+ // When initializing the DataProxy backend you can set the following
+ // attributes in the options object:
//
// * dataproxy: {url-to-proxy} (optional). Defaults to http://jsonpdataproxy.appspot.com
//
@@ -16,14 +18,14 @@ this.recline.Backend = this.recline.Backend || {};
// * format: (optional) csv | xls (defaults to csv if not specified)
//
// Note that this is a **read-only** backend.
- my.DataProxy = my.Base.extend({
- __type__: 'dataproxy',
- readonly: true,
- defaults: {
- dataproxy_url: 'http://jsonpdataproxy.appspot.com'
- },
- sync: function(method, model, options) {
- var self = this;
+ my.Backbone = function(options) {
+ var self = this;
+ this.__type__ = 'dataproxy';
+ this.readonly = true;
+
+ this.dataproxy_url = options && options.dataproxy_url ? options.dataproxy_url : 'http://jsonpdataproxy.appspot.com';
+
+ this.sync = function(method, model, options) {
if (method === "read") {
if (model.__type__ == 'Dataset') {
// Do nothing as we will get fields in query step (and no metadata to
@@ -35,22 +37,22 @@ this.recline.Backend = this.recline.Backend || {};
} else {
alert('This backend only supports read operations');
}
- },
- query: function(dataset, queryObj) {
+ };
+
+ this.query = function(dataset, queryObj) {
var self = this;
- var base = this.get('dataproxy_url');
var data = {
url: dataset.get('url'),
'max-results': queryObj.size,
type: dataset.get('format')
};
var jqxhr = $.ajax({
- url: base,
+ url: this.dataproxy_url,
data: data,
dataType: 'jsonp'
});
var dfd = $.Deferred();
- this._wrapInTimeout(jqxhr).done(function(results) {
+ _wrapInTimeout(jqxhr).done(function(results) {
if (results.error) {
dfd.reject(results.error);
}
@@ -65,13 +67,43 @@ this.recline.Backend = this.recline.Backend || {};
});
return tmp;
});
- dfd.resolve(self._docsToQueryResult(_out));
+ dfd.resolve({
+ total: null,
+ hits: _.map(_out, function(row) {
+ return { _source: row };
+ })
+ });
})
.fail(function(arguments) {
dfd.reject(arguments);
});
return dfd.promise();
- }
- });
+ };
+ };
-}(jQuery, this.recline.Backend));
+ // ## _wrapInTimeout
+ //
+ // Convenience method providing a crude way to catch backend errors on JSONP calls.
+ // 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 timeout = 5000;
+ var timer = setTimeout(function() {
+ dfd.reject({
+ message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds'
+ });
+ }, timeout);
+ ourFunction.done(function(arguments) {
+ clearTimeout(timer);
+ dfd.resolve(arguments);
+ })
+ .fail(function(arguments) {
+ clearTimeout(timer);
+ dfd.reject(arguments);
+ })
+ ;
+ return dfd.promise();
+ }
+
+}(jQuery, this.recline.Backend.DataProxy));
diff --git a/src/backend/elasticsearch.js b/src/backend/elasticsearch.js
index 546c0c8a..a73d4140 100644
--- a/src/backend/elasticsearch.js
+++ b/src/backend/elasticsearch.js
@@ -1,135 +1,93 @@
this.recline = this.recline || {};
this.recline.Backend = this.recline.Backend || {};
+this.recline.Backend.ElasticSearch = this.recline.Backend.ElasticSearch || {};
(function($, my) {
- // ## ElasticSearch Backend
+ // ## ElasticSearch Wrapper
//
- // Connecting to [ElasticSearch](http://www.elasticsearch.org/).
- //
- // Usage:
- //
- //
- // var backend = new recline.Backend.ElasticSearch({
- // // optional as can also be provided by Dataset/Document
- // url: {url to ElasticSearch endpoint i.e. ES 'type/table' url - more info below}
- // // optional
- // headers: {dict of headers to add to each request}
- // });
- //
- // @param {String} url: url for ElasticSearch type/table, e.g. for ES running
+ // Connecting to [ElasticSearch](http://www.elasticsearch.org/) endpoints.
+ // @param {String} endpoint: url for ElasticSearch type/table, e.g. for ES running
// on localhost:9200 with index // twitter and type tweet it would be:
//
// http://localhost:9200/twitter/tweet
//
- // This url is optional since the ES endpoint url may be specified on the the
- // dataset (and on a Document by the document having a dataset attribute) by
- // having one of the following (see also `_getESUrl` function):
+ // @param {Object} options: set of options such as:
//
- //
- // elasticsearch_url
- // webstore_url
- // url
- //
- my.ElasticSearch = my.Base.extend({
- __type__: 'elasticsearch',
- readonly: false,
- sync: function(method, model, options) {
- var self = this;
- if (method === "read") {
- if (model.__type__ == 'Dataset') {
- var schemaUrl = self._getESUrl(model) + '/_mapping';
- var jqxhr = this._makeRequest({
- url: schemaUrl,
- dataType: 'jsonp'
- });
- var dfd = $.Deferred();
- this._wrapInTimeout(jqxhr).done(function(schema) {
- // only one top level key in ES = the type so we can ignore it
- var key = _.keys(schema)[0];
- var fieldData = _.map(schema[key].properties, function(dict, fieldName) {
- dict.id = fieldName;
- return dict;
- });
- model.fields.reset(fieldData);
- dfd.resolve(model, jqxhr);
- })
- .fail(function(arguments) {
- dfd.reject(arguments);
- });
- return dfd.promise();
- } else if (model.__type__ == 'Document') {
- var base = this._getESUrl(model.dataset) + '/' + model.id;
- return this._makeRequest({
- url: base,
- dataType: 'json'
- });
- }
- } else if (method === 'update') {
- if (model.__type__ == 'Document') {
- return this.upsert(model.toJSON(), this._getESUrl(model.dataset));
- }
- } else if (method === 'delete') {
- if (model.__type__ == 'Document') {
- var url = this._getESUrl(model.dataset);
- return this.delete(model.id, url);
- }
- }
- },
+ // * headers - {dict of headers to add to each request}
+ // * dataType: dataType for AJAx requests e.g. set to jsonp to make jsonp requests (default is json requests)
+ my.Wrapper = function(endpoint, options) {
+ var self = this;
+ this.endpoint = endpoint;
+ this.options = _.extend({
+ dataType: 'json'
+ },
+ options);
+
+ // ### mapping
+ //
+ // Get ES mapping for this type/table
+ //
+ // @return promise compatible deferred object.
+ this.mapping = function() {
+ var schemaUrl = self.endpoint + '/_mapping';
+ var jqxhr = recline.Backend.makeRequest({
+ url: schemaUrl,
+ dataType: this.options.dataType
+ });
+ return jqxhr;
+ };
+
+ // ### get
+ //
+ // Get document corresponding to specified id
+ //
+ // @return promise compatible deferred object.
+ this.get = function(id) {
+ var base = this.endpoint + '/' + id;
+ return recline.Backend.makeRequest({
+ url: base,
+ dataType: 'json'
+ });
+ };
// ### upsert
//
// create / update a document to ElasticSearch backend
//
// @param {Object} doc an object to insert to the index.
- // @param {string} url (optional) url for ElasticSearch endpoint (if not
- // defined called this._getESUrl()
- upsert: function(doc, url) {
+ // @return deferred supporting promise API
+ this.upsert = function(doc) {
var data = JSON.stringify(doc);
- url = url ? url : this._getESUrl();
+ url = this.endpoint;
if (doc.id) {
url += '/' + doc.id;
}
- return this._makeRequest({
+ return recline.Backend.makeRequest({
url: url,
type: 'POST',
data: data,
dataType: 'json'
});
- },
+ };
// ### delete
//
// Delete a document from the ElasticSearch backend.
//
// @param {Object} id id of object to delete
- // @param {string} url (optional) url for ElasticSearch endpoint (if not
- // provided called this._getESUrl()
- delete: function(id, url) {
- url = url ? url : this._getESUrl();
+ // @return deferred supporting promise API
+ this.delete = function(id) {
+ url = this.endpoint;
url += '/' + id;
- return this._makeRequest({
+ return recline.Backend.makeRequest({
url: url,
type: 'DELETE',
dataType: 'json'
});
- },
+ };
- // ### _getESUrl
- //
- // get url to ElasticSearch endpoint (see above)
- _getESUrl: function(dataset) {
- if (dataset) {
- var out = dataset.get('elasticsearch_url');
- if (out) return out;
- out = dataset.get('webstore_url');
- if (out) return out;
- out = dataset.get('url');
- return out;
- }
- return this.get('url');
- },
- _normalizeQuery: function(queryObj) {
- var out = queryObj.toJSON ? queryObj.toJSON() : _.extend({}, queryObj);
+ this._normalizeQuery = function(queryObj) {
+ var out = queryObj && queryObj.toJSON ? queryObj.toJSON() : _.extend({}, queryObj);
if (out.q !== undefined && out.q.trim() === '') {
delete out.q;
}
@@ -159,17 +117,91 @@ this.recline.Backend = this.recline.Backend || {};
delete out.filters;
}
return out;
- },
- query: function(model, queryObj) {
+ };
+
+ // ### query
+ //
+ // @return deferred supporting promise API
+ this.query = function(queryObj) {
var queryNormalized = this._normalizeQuery(queryObj);
var data = {source: JSON.stringify(queryNormalized)};
- var base = this._getESUrl(model);
- var jqxhr = this._makeRequest({
- url: base + '/_search',
+ var url = this.endpoint + '/_search';
+ var jqxhr = recline.Backend.makeRequest({
+ url: url,
data: data,
- dataType: 'jsonp'
+ dataType: this.options.dataType
});
+ return jqxhr;
+ }
+ };
+
+ // ## ElasticSearch Backbone Backend
+ //
+ // Backbone connector for an ES backend.
+ //
+ // Usage:
+ //
+ // var backend = new recline.Backend.ElasticSearch(options);
+ //
+ // `options` are passed through to Wrapper
+ my.Backbone = function(options) {
+ var self = this;
+ var esOptions = options;
+ this.__type__ = 'elasticsearch';
+
+ // ### sync
+ //
+ // Backbone sync implementation for this backend.
+ //
+ // URL of ElasticSearch endpoint to use must be specified on the dataset
+ // (and on a Document via its dataset attribute) by the dataset having a
+ // url attribute.
+ this.sync = function(method, model, options) {
+ if (model.__type__ == 'Dataset') {
+ var endpoint = model.get('url');
+ } else {
+ var endpoint = model.dataset.get('url');
+ }
+ var es = new my.Wrapper(endpoint, esOptions);
+ if (method === "read") {
+ if (model.__type__ == 'Dataset') {
+ var dfd = $.Deferred();
+ es.mapping().done(function(schema) {
+ // only one top level key in ES = the type so we can ignore it
+ var key = _.keys(schema)[0];
+ var fieldData = _.map(schema[key].properties, function(dict, fieldName) {
+ dict.id = fieldName;
+ return dict;
+ });
+ model.fields.reset(fieldData);
+ dfd.resolve(model);
+ })
+ .fail(function(arguments) {
+ dfd.reject(arguments);
+ });
+ return dfd.promise();
+ } else if (model.__type__ == 'Document') {
+ return es.get(model.dataset.id);
+ }
+ } else if (method === 'update') {
+ if (model.__type__ == 'Document') {
+ return es.upsert(model.toJSON());
+ }
+ } else if (method === 'delete') {
+ if (model.__type__ == 'Document') {
+ return es.delete(model.id);
+ }
+ }
+ };
+
+ // ### query
+ //
+ // query the ES backend
+ this.query = function(model, queryObj) {
var dfd = $.Deferred();
+ var url = model.get('url');
+ var es = new my.Wrapper(url, esOptions);
+ var jqxhr = es.query(queryObj);
// TODO: fail case
jqxhr.done(function(results) {
_.each(results.hits.hits, function(hit) {
@@ -183,8 +215,8 @@ this.recline.Backend = this.recline.Backend || {};
dfd.resolve(results.hits);
});
return dfd.promise();
- }
- });
+ };
+ };
-}(jQuery, this.recline.Backend));
+}(jQuery, this.recline.Backend.ElasticSearch));
diff --git a/src/backend/gdocs.js b/src/backend/gdocs.js
index c9b5b551..c9449916 100644
--- a/src/backend/gdocs.js
+++ b/src/backend/gdocs.js
@@ -1,7 +1,9 @@
this.recline = this.recline || {};
this.recline.Backend = this.recline.Backend || {};
+this.recline.Backend.GDocs = this.recline.Backend.GDocs || {};
(function($, my) {
+
// ## Google spreadsheet backend
//
// Connect to Google Docs spreadsheet.
@@ -16,126 +18,154 @@ this.recline.Backend = this.recline.Backend || {};
// 'gdocs'
// );
//
- my.GDoc = my.Base.extend({
- __type__: 'gdoc',
- readonly: true,
- getUrl: function(dataset) {
- var url = dataset.get('url');
- if (url.indexOf('feeds/list') != -1) {
- return url;
- } else {
- // https://docs.google.com/spreadsheet/ccc?key=XXXX#gid=0
- var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+).*/;
- var matches = url.match(regex);
- if (matches) {
- var key = matches[1];
- var worksheet = 1;
- var out = 'https://spreadsheets.google.com/feeds/list/' + key + '/' + worksheet + '/public/values?alt=json';
- return out;
- } else {
- alert('Failed to extract gdocs key from ' + url);
- }
- }
- },
- sync: function(method, model, options) {
+ my.Backbone = function() {
+ var self = this;
+ this.__type__ = 'gdocs';
+ this.readonly = true;
+
+ this.sync = function(method, model, options) {
var self = this;
if (method === "read") {
var dfd = $.Deferred();
- var dataset = model;
-
- var url = this.getUrl(model);
-
- $.getJSON(url, function(d) {
- result = self.gdocsToJavascript(d);
- model.fields.reset(_.map(result.field, function(fieldId) {
- return {id: fieldId};
- })
- );
- // cache data onto dataset (we have loaded whole gdoc it seems!)
- model._dataCache = result.data;
- dfd.resolve(model);
- });
+ dfd.resolve(model);
return dfd.promise();
}
- },
+ };
- query: function(dataset, queryObj) {
+ this.query = function(dataset, queryObj) {
var dfd = $.Deferred();
- var fields = _.pluck(dataset.fields.toJSON(), 'id');
+ if (dataset._dataCache) {
+ dfd.resolve(dataset._dataCache);
+ } else {
+ loadData(dataset.get('url')).done(function(result) {
+ dataset.fields.reset(result.fields);
+ // cache data onto dataset (we have loaded whole gdoc it seems!)
+ dataset._dataCache = self._formatResults(dataset, result.data);
+ dfd.resolve(dataset._dataCache);
+ });
+ }
+ return dfd.promise();
+ };
+ this._formatResults = function(dataset, data) {
+ var fields = _.pluck(dataset.fields.toJSON(), 'id');
// zip the fields with the data rows to produce js objs
// TODO: factor this out as a common method with other backends
- var objs = _.map(dataset._dataCache, function (d) {
+ var objs = _.map(data, function (d) {
var obj = {};
_.each(_.zip(fields, d), function (x) {
obj[x[0]] = x[1];
});
return obj;
});
- dfd.resolve(this._docsToQueryResult(objs));
- return dfd;
- },
- gdocsToJavascript: function(gdocsSpreadsheet) {
- /*
- :options: (optional) optional argument dictionary:
- columnsToUse: list of columns to use (specified by field names)
- colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion).
- :return: tabular data object (hash with keys: field and data).
+ var out = {
+ total: objs.length,
+ hits: _.map(objs, function(row) {
+ return { _source: row }
+ })
+ }
+ return out;
+ };
+ };
- Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows.
- */
- var options = {};
- if (arguments.length > 1) {
- options = arguments[1];
- }
- var results = {
- 'field': [],
- 'data': []
- };
- // default is no special info on type of columns
- var colTypes = {};
- if (options.colTypes) {
- colTypes = options.colTypes;
- }
- // either extract column headings from spreadsheet directly, or used supplied ones
- if (options.columnsToUse) {
- // columns set to subset supplied
- results.field = options.columnsToUse;
- } else {
- // set columns to use to be all available
- if (gdocsSpreadsheet.feed.entry.length > 0) {
- for (var k in gdocsSpreadsheet.feed.entry[0]) {
- if (k.substr(0, 3) == 'gsx') {
- var col = k.substr(4);
- results.field.push(col);
- }
- }
- }
- }
-
- // converts non numberical values that should be numerical (22.3%[string] -> 0.223[float])
- var rep = /^([\d\.\-]+)\%$/;
- $.each(gdocsSpreadsheet.feed.entry, function (i, entry) {
- var row = [];
- for (var k in results.field) {
- var col = results.field[k];
- var _keyname = 'gsx$' + col;
- var value = entry[_keyname]['$t'];
- // if labelled as % and value contains %, convert
- if (colTypes[col] == 'percent') {
- if (rep.test(value)) {
- var value2 = rep.exec(value);
- var value3 = parseFloat(value2);
- value = value3 / 100;
- }
- }
- row.push(value);
- }
- results.data.push(row);
- });
- return results;
+ // ## loadData
+ //
+ // loadData from a google docs URL
+ //
+ // @return object with two attributes
+ //
+ // * fields: array of objects
+ // * data: array of arrays
+ var loadData = function(url) {
+ var dfd = $.Deferred();
+ var url = my.getSpreadsheetAPIUrl(url);
+ var out = {
+ fields: [],
+ data: []
}
- });
+ $.getJSON(url, function(d) {
+ result = my.parseData(d);
+ result.fields = _.map(result.fields, function(fieldId) {
+ return {id: fieldId};
+ });
+ dfd.resolve(result);
+ });
+ return dfd.promise();
+ };
-}(jQuery, this.recline.Backend));
+ // ## parseData
+ //
+ // Parse data from Google Docs API into a reasonable form
+ //
+ // :options: (optional) optional argument dictionary:
+ // columnsToUse: list of columns to use (specified by field names)
+ // colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion).
+ // :return: tabular data object (hash with keys: field and data).
+ //
+ // Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows.
+ my.parseData = function(gdocsSpreadsheet) {
+ var options = {};
+ if (arguments.length > 1) {
+ options = arguments[1];
+ }
+ var results = {
+ 'fields': [],
+ 'data': []
+ };
+ // default is no special info on type of columns
+ var colTypes = {};
+ if (options.colTypes) {
+ colTypes = options.colTypes;
+ }
+ if (gdocsSpreadsheet.feed.entry.length > 0) {
+ for (var k in gdocsSpreadsheet.feed.entry[0]) {
+ if (k.substr(0, 3) == 'gsx') {
+ var col = k.substr(4);
+ results.fields.push(col);
+ }
+ }
+ }
+
+ // converts non numberical values that should be numerical (22.3%[string] -> 0.223[float])
+ var rep = /^([\d\.\-]+)\%$/;
+ $.each(gdocsSpreadsheet.feed.entry, function (i, entry) {
+ var row = [];
+ for (var k in results.fields) {
+ var col = results.fields[k];
+ var _keyname = 'gsx$' + col;
+ var value = entry[_keyname]['$t'];
+ // if labelled as % and value contains %, convert
+ if (colTypes[col] == 'percent') {
+ if (rep.test(value)) {
+ var value2 = rep.exec(value);
+ var value3 = parseFloat(value2);
+ value = value3 / 100;
+ }
+ }
+ row.push(value);
+ }
+ results.data.push(row);
+ });
+ return results;
+ };
+
+ // Convenience function to get GDocs JSON API Url from standard URL
+ my.getSpreadsheetAPIUrl = function(url) {
+ if (url.indexOf('feeds/list') != -1) {
+ return url;
+ } else {
+ // https://docs.google.com/spreadsheet/ccc?key=XXXX#gid=0
+ var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+).*/;
+ var matches = url.match(regex);
+ if (matches) {
+ var key = matches[1];
+ var worksheet = 1;
+ var out = 'https://spreadsheets.google.com/feeds/list/' + key + '/' + worksheet + '/public/values?alt=json';
+ return out;
+ } else {
+ alert('Failed to extract gdocs key from ' + url);
+ }
+ }
+ };
+}(jQuery, this.recline.Backend.GDocs));
diff --git a/src/backend/memory.js b/src/backend/memory.js
index 4783c20d..f7fa8afb 100644
--- a/src/backend/memory.js
+++ b/src/backend/memory.js
@@ -1,5 +1,6 @@
this.recline = this.recline || {};
this.recline.Backend = this.recline.Backend || {};
+this.recline.Backend.Memory = this.recline.Backend.Memory || {};
(function($, my) {
// ## createDataset
@@ -14,115 +15,54 @@ this.recline.Backend = this.recline.Backend || {};
// @param metadata: (optional) dataset metadata - see recline.Model.Dataset.
// If not defined (or id not provided) id will be autogenerated.
my.createDataset = function(data, fields, metadata) {
- if (!metadata) {
- metadata = {};
- }
- if (!metadata.id) {
- metadata.id = String(Math.floor(Math.random() * 100000000) + 1);
- }
- var backend = new recline.Backend.Memory();
- var datasetInfo = {
- documents: data,
- metadata: metadata
- };
- if (fields) {
- datasetInfo.fields = fields;
- } else {
- if (data) {
- datasetInfo.fields = _.map(data[0], function(value, key) {
- return {id: key};
- });
- }
- }
- backend.addDataset(datasetInfo);
- var dataset = new recline.Model.Dataset({id: metadata.id}, backend);
+ var wrapper = new my.DataWrapper(data, fields);
+ var backend = new my.Backbone();
+ var dataset = new recline.Model.Dataset(metadata, backend);
+ dataset._dataCache = wrapper;
dataset.fetch();
dataset.query();
return dataset;
};
-
- // ## Memory Backend - uses in-memory data
+ // ## Data Wrapper
//
- // To use it you should provide in your constructor data:
- //
- // * metadata (including fields array)
- // * documents: list of hashes, each hash being one doc. A doc *must* have an id attribute which is unique.
- //
- // Example:
- //
- //
- // // Backend setup
- // var backend = recline.Backend.Memory();
- // backend.addDataset({
- // metadata: {
- // id: 'my-id',
- // title: 'My Title'
- // },
- // fields: [{id: 'x'}, {id: 'y'}, {id: 'z'}],
- // documents: [
- // {id: 0, x: 1, y: 2, z: 3},
- // {id: 1, x: 2, y: 4, z: 6}
- // ]
- // });
- // // later ...
- // var dataset = Dataset({id: 'my-id'}, 'memory');
- // dataset.fetch();
- // etc ...
- //
- my.Memory = my.Base.extend({
- __type__: 'memory',
- readonly: false,
- initialize: function() {
- this.datasets = {};
- },
- addDataset: function(data) {
- this.datasets[data.metadata.id] = $.extend(true, {}, data);
- },
- sync: function(method, model, options) {
- var self = this;
- var dfd = $.Deferred();
- if (method === "read") {
- if (model.__type__ == 'Dataset') {
- var rawDataset = this.datasets[model.id];
- model.set(rawDataset.metadata);
- model.fields.reset(rawDataset.fields);
- model.docCount = rawDataset.documents.length;
- dfd.resolve(model);
- }
- return dfd.promise();
- } else if (method === 'update') {
- if (model.__type__ == 'Document') {
- _.each(self.datasets[model.dataset.id].documents, function(doc, idx) {
- if(doc.id === model.id) {
- self.datasets[model.dataset.id].documents[idx] = model.toJSON();
- }
- });
- dfd.resolve(model);
- }
- return dfd.promise();
- } else if (method === 'delete') {
- if (model.__type__ == 'Document') {
- var rawDataset = self.datasets[model.dataset.id];
- var newdocs = _.reject(rawDataset.documents, function(doc) {
- return (doc.id === model.id);
- });
- rawDataset.documents = newdocs;
- dfd.resolve(model);
- }
- return dfd.promise();
- } else {
- alert('Not supported: sync on Memory backend with method ' + method + ' and model ' + model);
+ // Turn a simple array of JS objects into a mini data-store with
+ // functionality like querying, faceting, updating (by ID) and deleting (by
+ // ID).
+ my.DataWrapper = function(data, fields) {
+ var self = this;
+ this.data = data;
+ if (fields) {
+ this.fields = fields;
+ } else {
+ if (data) {
+ this.fields = _.map(data[0], function(value, key) {
+ return {id: key};
+ });
}
- },
- query: function(model, queryObj) {
- var dfd = $.Deferred();
- var out = {};
- var numRows = queryObj.size;
- var start = queryObj.from;
- var results = this.datasets[model.id].documents;
+ }
+
+ this.update = function(doc) {
+ _.each(self.data, function(internalDoc, idx) {
+ if(doc.id === internalDoc.id) {
+ self.data[idx] = doc;
+ }
+ });
+ };
+
+ this.delete = function(doc) {
+ var newdocs = _.reject(self.data, function(internalDoc) {
+ return (doc.id === internalDoc.id);
+ });
+ this.data = newdocs;
+ };
+
+ this.query = function(queryObj) {
+ var numRows = queryObj.size || this.data.length;
+ var start = queryObj.from || 0;
+ var results = this.data;
results = this._applyFilters(results, queryObj);
- results = this._applyFreeTextQuery(model, results, queryObj);
+ results = this._applyFreeTextQuery(results, queryObj);
// not complete sorting!
_.each(queryObj.sort, function(sortObj) {
var fieldName = _.keys(sortObj)[0];
@@ -131,17 +71,18 @@ this.recline.Backend = this.recline.Backend || {};
return (sortObj[fieldName].order == 'asc') ? _out : -1*_out;
});
});
- out.facets = this._computeFacets(results, queryObj);
var total = results.length;
- resultsObj = this._docsToQueryResult(results.slice(start, start+numRows));
- _.extend(out, resultsObj);
- out.total = total;
- dfd.resolve(out);
- return dfd.promise();
- },
+ var facets = this.computeFacets(results, queryObj);
+ results = results.slice(start, start+numRows);
+ return {
+ total: total,
+ documents: results,
+ facets: facets
+ };
+ };
// in place filtering
- _applyFilters: function(results, queryObj) {
+ this._applyFilters = function(results, queryObj) {
_.each(queryObj.filters, function(filter) {
results = _.filter(results, function(doc) {
var fieldId = _.keys(filter.term)[0];
@@ -149,17 +90,17 @@ this.recline.Backend = this.recline.Backend || {};
});
});
return results;
- },
+ };
// we OR across fields but AND across terms in query string
- _applyFreeTextQuery: function(dataset, results, queryObj) {
+ this._applyFreeTextQuery = function(results, queryObj) {
if (queryObj.q) {
var terms = queryObj.q.split(' ');
results = _.filter(results, function(rawdoc) {
var matches = true;
_.each(terms, function(term) {
var foundmatch = false;
- dataset.fields.each(function(field) {
+ _.each(self.fields, function(field) {
var value = rawdoc[field.id];
if (value !== null) { value = value.toString(); }
// TODO regexes?
@@ -175,14 +116,15 @@ this.recline.Backend = this.recline.Backend || {};
});
}
return results;
- },
+ };
- _computeFacets: function(documents, queryObj) {
+ this.computeFacets = function(documents, queryObj) {
var facetResults = {};
if (!queryObj.facets) {
- return facetsResults;
+ return facetResults;
}
_.each(queryObj.facets, function(query, facetId) {
+ // TODO: remove dependency on recline.Model
facetResults[facetId] = new recline.Model.Facet({id: facetId}).toJSON();
facetResults[facetId].termsall = {};
});
@@ -211,7 +153,55 @@ this.recline.Backend = this.recline.Backend || {};
tmp.terms = tmp.terms.slice(0, 10);
});
return facetResults;
- }
- });
+ };
+ };
+
-}(jQuery, this.recline.Backend));
+ // ## Backbone
+ //
+ // Backbone connector for memory store attached to a Dataset object
+ my.Backbone = function() {
+ this.__type__ = 'memory';
+ this.sync = function(method, model, options) {
+ var self = this;
+ var dfd = $.Deferred();
+ if (method === "read") {
+ if (model.__type__ == 'Dataset') {
+ model.fields.reset(model._dataCache.fields);
+ dfd.resolve(model);
+ }
+ return dfd.promise();
+ } else if (method === 'update') {
+ if (model.__type__ == 'Document') {
+ model.dataset._dataCache.update(model.toJSON());
+ dfd.resolve(model);
+ }
+ return dfd.promise();
+ } else if (method === 'delete') {
+ if (model.__type__ == 'Document') {
+ model.dataset._dataCache.delete(model.toJSON());
+ dfd.resolve(model);
+ }
+ return dfd.promise();
+ } else {
+ alert('Not supported: sync on Memory backend with method ' + method + ' and model ' + model);
+ }
+ };
+
+ this.query = function(model, queryObj) {
+ var dfd = $.Deferred();
+ var results = model._dataCache.query(queryObj);
+ var hits = _.map(results.documents, function(row) {
+ return { _source: row };
+ });
+ var out = {
+ total: results.total,
+ hits: hits,
+ facets: results.facets
+ };
+ dfd.resolve(out);
+ return dfd.promise();
+ };
+ };
+
+}(jQuery, this.recline.Backend.Memory));
diff --git a/src/model.js b/src/model.js
index aca6c1e3..9d4c690a 100644
--- a/src/model.js
+++ b/src/model.js
@@ -133,7 +133,7 @@ my.Dataset = Backbone.Model.extend({
if (recline && recline.Backend) {
_.each(_.keys(recline.Backend), function(name) {
if (name.toLowerCase() === backendString.toLowerCase()) {
- backend = new recline.Backend[name]();
+ backend = new recline.Backend[name].Backbone();
}
});
}
@@ -156,20 +156,20 @@ my.Dataset = Backbone.Model.extend({
// ...
// }
my.Dataset.restore = function(state) {
- // hack-y - restoring a memory dataset does not mean much ...
var dataset = null;
- if (state.url && !state.dataset) {
- state.dataset = {url: state.url};
- }
+ // hack-y - restoring a memory dataset does not mean much ...
if (state.backend === 'memory') {
- dataset = recline.Backend.createDataset(
+ dataset = recline.Backend.Memory.createDataset(
[{stub: 'this is a stub dataset because we do not restore memory datasets'}],
[],
state.dataset // metadata
);
} else {
+ var datasetInfo = {
+ url: state.url
+ };
dataset = new recline.Model.Dataset(
- state.dataset,
+ datasetInfo,
state.backend
);
}
@@ -493,10 +493,12 @@ my.ObjectState = Backbone.Model.extend({
});
-// ## Backend registry
+// ## Backbone.sync
//
-// Backends will register themselves by id into this registry
-my.backends = {};
+// Override Backbone.sync to hand off to sync function in relevant backend
+Backbone.sync = function(method, model, options) {
+ return model.backend.sync(method, model, options);
+};
}(jQuery, this.recline.Model));
diff --git a/src/view-graph.js b/src/view-graph.js
index c10c31a5..6b5374c4 100644
--- a/src/view-graph.js
+++ b/src/view-graph.js
@@ -27,13 +27,6 @@ my.Graph = Backbone.View.extend({
template: ' \
\
-
\
-
Help »
\
-
To create a chart select a column (group) to use as the x-axis \
- then another column (Series A) to plot against it.
\
-
You can add add \
- additional series by clicking the "Add series" button
\
-
\
').css({position:this.element.css("position"),width:this.element.outerWidth(),height:this.element.outerHeight(),
+top:this.element.css("top"),left:this.element.css("left")}));this.element=this.element.parent().data("resizable",this.element.data("resizable"));this.elementIsWrapper=true;this.element.css({marginLeft:this.originalElement.css("marginLeft"),marginTop:this.originalElement.css("marginTop"),marginRight:this.originalElement.css("marginRight"),marginBottom:this.originalElement.css("marginBottom")});this.originalElement.css({marginLeft:0,marginTop:0,marginRight:0,marginBottom:0});this.originalResizeStyle=
+this.originalElement.css("resize");this.originalElement.css("resize","none");this._proportionallyResizeElements.push(this.originalElement.css({position:"static",zoom:1,display:"block"}));this.originalElement.css({margin:this.originalElement.css("margin")});this._proportionallyResize()}this.handles=a.handles||(!e(".ui-resizable-handle",this.element).length?"e,s,se":{n:".ui-resizable-n",e:".ui-resizable-e",s:".ui-resizable-s",w:".ui-resizable-w",se:".ui-resizable-se",sw:".ui-resizable-sw",ne:".ui-resizable-ne",
+nw:".ui-resizable-nw"});if(this.handles.constructor==String){if(this.handles=="all")this.handles="n,e,s,w,se,sw,ne,nw";var c=this.handles.split(",");this.handles={};for(var d=0;d
');/sw|se|ne|nw/.test(f)&&g.css({zIndex:++a.zIndex});"se"==f&&g.addClass("ui-icon ui-icon-gripsmall-diagonal-se");this.handles[f]=".ui-resizable-"+f;this.element.append(g)}}this._renderAxis=function(h){h=h||this.element;for(var i in this.handles){if(this.handles[i].constructor==
+String)this.handles[i]=e(this.handles[i],this.element).show();if(this.elementIsWrapper&&this.originalElement[0].nodeName.match(/textarea|input|select|button/i)){var j=e(this.handles[i],this.element),l=0;l=/sw|ne|nw|se|n|s/.test(i)?j.outerHeight():j.outerWidth();j=["padding",/ne|nw|n/.test(i)?"Top":/se|sw|s/.test(i)?"Bottom":/^e$/.test(i)?"Right":"Left"].join("");h.css(j,l);this._proportionallyResize()}e(this.handles[i])}};this._renderAxis(this.element);this._handles=e(".ui-resizable-handle",this.element).disableSelection();
+this._handles.mouseover(function(){if(!b.resizing){if(this.className)var h=this.className.match(/ui-resizable-(se|sw|ne|nw|n|e|s|w)/i);b.axis=h&&h[1]?h[1]:"se"}});if(a.autoHide){this._handles.hide();e(this.element).addClass("ui-resizable-autohide").hover(function(){if(!a.disabled){e(this).removeClass("ui-resizable-autohide");b._handles.show()}},function(){if(!a.disabled)if(!b.resizing){e(this).addClass("ui-resizable-autohide");b._handles.hide()}})}this._mouseInit()},destroy:function(){this._mouseDestroy();
+var b=function(c){e(c).removeClass("ui-resizable ui-resizable-disabled ui-resizable-resizing").removeData("resizable").unbind(".resizable").find(".ui-resizable-handle").remove()};if(this.elementIsWrapper){b(this.element);var a=this.element;a.after(this.originalElement.css({position:a.css("position"),width:a.outerWidth(),height:a.outerHeight(),top:a.css("top"),left:a.css("left")})).remove()}this.originalElement.css("resize",this.originalResizeStyle);b(this.originalElement);return this},_mouseCapture:function(b){var a=
+false;for(var c in this.handles)if(e(this.handles[c])[0]==b.target)a=true;return!this.options.disabled&&a},_mouseStart:function(b){var a=this.options,c=this.element.position(),d=this.element;this.resizing=true;this.documentScroll={top:e(document).scrollTop(),left:e(document).scrollLeft()};if(d.is(".ui-draggable")||/absolute/.test(d.css("position")))d.css({position:"absolute",top:c.top,left:c.left});e.browser.opera&&/relative/.test(d.css("position"))&&d.css({position:"relative",top:"auto",left:"auto"});
+this._renderProxy();c=m(this.helper.css("left"));var f=m(this.helper.css("top"));if(a.containment){c+=e(a.containment).scrollLeft()||0;f+=e(a.containment).scrollTop()||0}this.offset=this.helper.offset();this.position={left:c,top:f};this.size=this._helper?{width:d.outerWidth(),height:d.outerHeight()}:{width:d.width(),height:d.height()};this.originalSize=this._helper?{width:d.outerWidth(),height:d.outerHeight()}:{width:d.width(),height:d.height()};this.originalPosition={left:c,top:f};this.sizeDiff=
+{width:d.outerWidth()-d.width(),height:d.outerHeight()-d.height()};this.originalMousePosition={left:b.pageX,top:b.pageY};this.aspectRatio=typeof a.aspectRatio=="number"?a.aspectRatio:this.originalSize.width/this.originalSize.height||1;a=e(".ui-resizable-"+this.axis).css("cursor");e("body").css("cursor",a=="auto"?this.axis+"-resize":a);d.addClass("ui-resizable-resizing");this._propagate("start",b);return true},_mouseDrag:function(b){var a=this.helper,c=this.originalMousePosition,d=this._change[this.axis];
+if(!d)return false;c=d.apply(this,[b,b.pageX-c.left||0,b.pageY-c.top||0]);this._updateVirtualBoundaries(b.shiftKey);if(this._aspectRatio||b.shiftKey)c=this._updateRatio(c,b);c=this._respectSize(c,b);this._propagate("resize",b);a.css({top:this.position.top+"px",left:this.position.left+"px",width:this.size.width+"px",height:this.size.height+"px"});!this._helper&&this._proportionallyResizeElements.length&&this._proportionallyResize();this._updateCache(c);this._trigger("resize",b,this.ui());return false},
+_mouseStop:function(b){this.resizing=false;var a=this.options,c=this;if(this._helper){var d=this._proportionallyResizeElements,f=d.length&&/textarea/i.test(d[0].nodeName);d=f&&e.ui.hasScroll(d[0],"left")?0:c.sizeDiff.height;f=f?0:c.sizeDiff.width;f={width:c.helper.width()-f,height:c.helper.height()-d};d=parseInt(c.element.css("left"),10)+(c.position.left-c.originalPosition.left)||null;var g=parseInt(c.element.css("top"),10)+(c.position.top-c.originalPosition.top)||null;a.animate||this.element.css(e.extend(f,
+{top:g,left:d}));c.helper.height(c.size.height);c.helper.width(c.size.width);this._helper&&!a.animate&&this._proportionallyResize()}e("body").css("cursor","auto");this.element.removeClass("ui-resizable-resizing");this._propagate("stop",b);this._helper&&this.helper.remove();return false},_updateVirtualBoundaries:function(b){var a=this.options,c,d,f;a={minWidth:k(a.minWidth)?a.minWidth:0,maxWidth:k(a.maxWidth)?a.maxWidth:Infinity,minHeight:k(a.minHeight)?a.minHeight:0,maxHeight:k(a.maxHeight)?a.maxHeight:
+Infinity};if(this._aspectRatio||b){b=a.minHeight*this.aspectRatio;d=a.minWidth/this.aspectRatio;c=a.maxHeight*this.aspectRatio;f=a.maxWidth/this.aspectRatio;if(b>a.minWidth)a.minWidth=b;if(d>a.minHeight)a.minHeight=d;if(c