4822 lines
145 KiB
JavaScript
4822 lines
145 KiB
JavaScript
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) {
|
||
"use strict";
|
||
my.__type__ = 'csv';
|
||
|
||
// use either jQuery or Underscore Deferred depending on what is available
|
||
var Deferred = (typeof jQuery !== "undefined" && jQuery.Deferred) || _.Deferred;
|
||
|
||
// ## fetch
|
||
//
|
||
// fetch supports 3 options depending on the attribute provided on the dataset argument
|
||
//
|
||
// 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 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:
|
||
//
|
||
// <pre>
|
||
// {
|
||
// records: [ [...], [...], ... ],
|
||
// metadata: { may be some metadata e.g. file name }
|
||
// useMemoryStore: true
|
||
// }
|
||
// </pre>
|
||
my.fetch = function(dataset) {
|
||
var dfd = new Deferred();
|
||
if (dataset.file) {
|
||
var reader = new FileReader();
|
||
var encoding = dataset.encoding || 'UTF-8';
|
||
reader.onload = function(e) {
|
||
var rows = my.parseCSV(e.target.result, dataset);
|
||
dfd.resolve({
|
||
records: rows,
|
||
metadata: {
|
||
filename: dataset.file.name
|
||
},
|
||
useMemoryStore: true
|
||
});
|
||
};
|
||
reader.onerror = function (e) {
|
||
alert('Failed to load file. Code: ' + e.target.error.code);
|
||
};
|
||
reader.readAsText(dataset.file, encoding);
|
||
} else if (dataset.data) {
|
||
var rows = my.parseCSV(dataset.data, dataset);
|
||
dfd.resolve({
|
||
records: rows,
|
||
useMemoryStore: true
|
||
});
|
||
} else if (dataset.url) {
|
||
jQuery.get(dataset.url).done(function(data) {
|
||
var rows = my.parseCSV(data, dataset);
|
||
dfd.resolve({
|
||
records: rows,
|
||
useMemoryStore: true
|
||
});
|
||
});
|
||
}
|
||
return dfd.promise();
|
||
};
|
||
|
||
// ## parseCSV
|
||
//
|
||
// Converts a Comma Separated Values string into an array of arrays.
|
||
// Each line in the CSV becomes an array.
|
||
//
|
||
// Empty fields are converted to nulls and non-quoted numbers are converted to integers or floats.
|
||
//
|
||
// @return The CSV parsed as an array
|
||
// @type Array
|
||
//
|
||
// @param {String} s The string to convert
|
||
// @param {Object} options Options for loading CSV including
|
||
// @param {Boolean} [trim=false] If set to True leading and trailing
|
||
// whitespace is stripped off of each non-quoted field as it is imported
|
||
// @param {String} [delimiter=','] A one-character string used to separate
|
||
// fields. It defaults to ','
|
||
// @param {String} [quotechar='"'] A one-character string used to quote
|
||
// fields containing special characters, such as the delimiter or
|
||
// quotechar, or which contain new-line characters. It defaults to '"'
|
||
//
|
||
// Heavily based on uselesscode's JS CSV parser (MIT Licensed):
|
||
// http://www.uselesscode.org/javascript/csv/
|
||
my.parseCSV= function(s, options) {
|
||
// Get rid of any trailing \n
|
||
s = chomp(s);
|
||
|
||
var options = options || {};
|
||
var trm = (options.trim === false) ? false : true;
|
||
var delimiter = options.delimiter || ',';
|
||
var quotechar = options.quotechar || '"';
|
||
|
||
var cur = '', // The character we are currently processing.
|
||
inQuote = false,
|
||
fieldQuoted = false,
|
||
field = '', // Buffer for building up the current field
|
||
row = [],
|
||
out = [],
|
||
i,
|
||
processField;
|
||
|
||
processField = function (field) {
|
||
if (fieldQuoted !== true) {
|
||
// If field is empty set to null
|
||
if (field === '') {
|
||
field = null;
|
||
// If the field was not quoted and we are trimming fields, trim it
|
||
} else if (trm === true) {
|
||
field = trim(field);
|
||
}
|
||
|
||
// Convert unquoted numbers to their appropriate types
|
||
if (rxIsInt.test(field)) {
|
||
field = parseInt(field, 10);
|
||
} else if (rxIsFloat.test(field)) {
|
||
field = parseFloat(field, 10);
|
||
}
|
||
}
|
||
return field;
|
||
};
|
||
|
||
for (i = 0; i < s.length; i += 1) {
|
||
cur = s.charAt(i);
|
||
|
||
// If we are at a EOF or EOR
|
||
if (inQuote === false && (cur === delimiter || cur === "\n")) {
|
||
field = processField(field);
|
||
// Add the current field to the current row
|
||
row.push(field);
|
||
// If this is EOR append row to output and flush row
|
||
if (cur === "\n") {
|
||
out.push(row);
|
||
row = [];
|
||
}
|
||
// Flush the field buffer
|
||
field = '';
|
||
fieldQuoted = false;
|
||
} else {
|
||
// If it's not a quotechar, add it to the field buffer
|
||
if (cur !== quotechar) {
|
||
field += cur;
|
||
} else {
|
||
if (!inQuote) {
|
||
// We are not in a quote, start a quote
|
||
inQuote = true;
|
||
fieldQuoted = true;
|
||
} else {
|
||
// Next char is quotechar, this is an escaped quotechar
|
||
if (s.charAt(i + 1) === quotechar) {
|
||
field += quotechar;
|
||
// Skip the next char
|
||
i += 1;
|
||
} else {
|
||
// It's not escaping, so end quote
|
||
inQuote = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add the last field
|
||
field = processField(field);
|
||
row.push(field);
|
||
out.push(row);
|
||
|
||
// Expose the ability to discard initial rows
|
||
if (options.skipInitialRows) out = out.slice(options.skipInitialRows);
|
||
|
||
return out;
|
||
};
|
||
|
||
// ## serializeCSV
|
||
//
|
||
// Convert an Object or a simple array of arrays into a Comma
|
||
// Separated Values string.
|
||
//
|
||
// Nulls are converted to empty fields and integers or floats are converted to non-quoted numbers.
|
||
//
|
||
// @return The array serialized as a CSV
|
||
// @type String
|
||
//
|
||
// @param {Object or Array} dataToSerialize The Object or array of arrays to convert. Object structure must be as follows:
|
||
//
|
||
// {
|
||
// fields: [ {id: .., ...}, {id: ...,
|
||
// records: [ { record }, { record }, ... ]
|
||
// ... // more attributes we do not care about
|
||
// }
|
||
//
|
||
// @param {object} options Options for serializing the CSV file including
|
||
// delimiter and quotechar (see parseCSV options parameter above for
|
||
// details on these).
|
||
//
|
||
// Heavily based on uselesscode's JS CSV serializer (MIT Licensed):
|
||
// http://www.uselesscode.org/javascript/csv/
|
||
my.serializeCSV= function(dataToSerialize, options) {
|
||
var a = null;
|
||
if (dataToSerialize instanceof Array) {
|
||
a = dataToSerialize;
|
||
} else {
|
||
a = [];
|
||
var fieldNames = _.pluck(dataToSerialize.fields, 'id');
|
||
a.push(fieldNames);
|
||
_.each(dataToSerialize.records, function(record, index) {
|
||
var tmp = _.map(fieldNames, function(fn) {
|
||
return record[fn];
|
||
});
|
||
a.push(tmp);
|
||
});
|
||
}
|
||
var options = options || {};
|
||
var delimiter = options.delimiter || ',';
|
||
var quotechar = options.quotechar || '"';
|
||
|
||
var cur = '', // The character we are currently processing.
|
||
field = '', // Buffer for building up the current field
|
||
row = '',
|
||
out = '',
|
||
i,
|
||
j,
|
||
processField;
|
||
|
||
processField = function (field) {
|
||
if (field === null) {
|
||
// If field is null set to empty string
|
||
field = '';
|
||
} else if (typeof field === "string" && rxNeedsQuoting.test(field)) {
|
||
// Convert string to delimited string
|
||
field = quotechar + field + quotechar;
|
||
} else if (typeof field === "number") {
|
||
// Convert number to string
|
||
field = field.toString(10);
|
||
}
|
||
|
||
return field;
|
||
};
|
||
|
||
for (i = 0; i < a.length; i += 1) {
|
||
cur = a[i];
|
||
|
||
for (j = 0; j < cur.length; j += 1) {
|
||
field = processField(cur[j]);
|
||
// If this is EOR append row to output and flush row
|
||
if (j === (cur.length - 1)) {
|
||
row += field;
|
||
out += row + "\n";
|
||
row = '';
|
||
} else {
|
||
// Add the current field to the current row
|
||
row += field + delimiter;
|
||
}
|
||
// Flush the field buffer
|
||
field = '';
|
||
}
|
||
}
|
||
|
||
return out;
|
||
};
|
||
|
||
var rxIsInt = /^\d+$/,
|
||
rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/,
|
||
// If a string has leading or trailing space,
|
||
// contains a comma double quote or a newline
|
||
// it needs to be quoted in CSV output
|
||
rxNeedsQuoting = /^\s|\s$|,|"|\n/,
|
||
trim = (function () {
|
||
// Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists
|
||
if (String.prototype.trim) {
|
||
return function (s) {
|
||
return s.trim();
|
||
};
|
||
} else {
|
||
return function (s) {
|
||
return s.replace(/^\s*/, '').replace(/\s*$/, '');
|
||
};
|
||
}
|
||
}());
|
||
|
||
function chomp(s) {
|
||
if (s.charAt(s.length - 1) !== "\n") {
|
||
// Does not end with \n, just return string
|
||
return s;
|
||
} else {
|
||
// Remove the \n
|
||
return s.substring(0, s.length - 1);
|
||
}
|
||
}
|
||
|
||
|
||
}(this.recline.Backend.CSV));
|
||
this.recline = this.recline || {};
|
||
this.recline.Backend = this.recline.Backend || {};
|
||
this.recline.Backend.DataProxy = this.recline.Backend.DataProxy || {};
|
||
|
||
(function(my) {
|
||
"use strict";
|
||
my.__type__ = 'dataproxy';
|
||
// URL for the dataproxy
|
||
my.dataproxy_url = '//jsonpdataproxy.appspot.com';
|
||
// Timeout for dataproxy (after this time if no response we error)
|
||
// Needed because use JSONP so do not receive e.g. 500 errors
|
||
my.timeout = 5000;
|
||
|
||
|
||
// use either jQuery or Underscore Deferred depending on what is available
|
||
var Deferred = (typeof jQuery !== "undefined" && jQuery.Deferred) || _.Deferred;
|
||
|
||
// ## load
|
||
//
|
||
// Load data from a URL via the [DataProxy](http://github.com/okfn/dataproxy).
|
||
//
|
||
// Returns array of field names and array of arrays for records
|
||
my.fetch = function(dataset) {
|
||
var data = {
|
||
url: dataset.url,
|
||
'max-results': dataset.size || dataset.rows || 1000,
|
||
type: dataset.format || ''
|
||
};
|
||
var jqxhr = jQuery.ajax({
|
||
url: my.dataproxy_url,
|
||
data: data,
|
||
dataType: 'jsonp'
|
||
});
|
||
var dfd = new Deferred();
|
||
_wrapInTimeout(jqxhr).done(function(results) {
|
||
if (results.error) {
|
||
dfd.reject(results.error);
|
||
}
|
||
|
||
dfd.resolve({
|
||
records: results.data,
|
||
fields: results.fields,
|
||
useMemoryStore: true
|
||
});
|
||
})
|
||
.fail(function(args) {
|
||
dfd.reject(args);
|
||
});
|
||
return dfd.promise();
|
||
};
|
||
|
||
// ## _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 = new Deferred();
|
||
var timer = setTimeout(function() {
|
||
dfd.reject({
|
||
message: 'Request Error: Backend did not respond after ' + (my.timeout / 1000) + ' seconds'
|
||
});
|
||
}, my.timeout);
|
||
ourFunction.done(function(args) {
|
||
clearTimeout(timer);
|
||
dfd.resolve(args);
|
||
})
|
||
.fail(function(args) {
|
||
clearTimeout(timer);
|
||
dfd.reject(args);
|
||
})
|
||
;
|
||
return dfd.promise();
|
||
};
|
||
|
||
}(this.recline.Backend.DataProxy));
|
||
this.recline = this.recline || {};
|
||
this.recline.Backend = this.recline.Backend || {};
|
||
this.recline.Backend.ElasticSearch = this.recline.Backend.ElasticSearch || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
my.__type__ = 'elasticsearch';
|
||
|
||
// use either jQuery or Underscore Deferred depending on what is available
|
||
var Deferred = (typeof jQuery !== "undefined" && jQuery.Deferred) || _.Deferred;
|
||
|
||
// ## ElasticSearch Wrapper
|
||
//
|
||
// A simple JS wrapper around an [ElasticSearch](http://www.elasticsearch.org/) endpoints.
|
||
//
|
||
// @param {String} endpoint: url for ElasticSearch type/table, e.g. for ES running
|
||
// on http://localhost:9200 with index twitter and type tweet it would be:
|
||
//
|
||
// <pre>http://localhost:9200/twitter/tweet</pre>
|
||
//
|
||
// @param {Object} options: set of options such as:
|
||
//
|
||
// * 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 = makeRequest({
|
||
url: schemaUrl,
|
||
dataType: this.options.dataType
|
||
});
|
||
return jqxhr;
|
||
};
|
||
|
||
// ### get
|
||
//
|
||
// Get record corresponding to specified id
|
||
//
|
||
// @return promise compatible deferred object.
|
||
this.get = function(id) {
|
||
var base = this.endpoint + '/' + id;
|
||
return makeRequest({
|
||
url: base,
|
||
dataType: 'json'
|
||
});
|
||
};
|
||
|
||
// ### upsert
|
||
//
|
||
// create / update a record to ElasticSearch backend
|
||
//
|
||
// @param {Object} doc an object to insert to the index.
|
||
// @return deferred supporting promise API
|
||
this.upsert = function(doc) {
|
||
var data = JSON.stringify(doc);
|
||
url = this.endpoint;
|
||
if (doc.id) {
|
||
url += '/' + doc.id;
|
||
}
|
||
return makeRequest({
|
||
url: url,
|
||
type: 'POST',
|
||
data: data,
|
||
dataType: 'json'
|
||
});
|
||
};
|
||
|
||
// ### delete
|
||
//
|
||
// Delete a record from the ElasticSearch backend.
|
||
//
|
||
// @param {Object} id id of object to delete
|
||
// @return deferred supporting promise API
|
||
this.remove = function(id) {
|
||
url = this.endpoint;
|
||
url += '/' + id;
|
||
return makeRequest({
|
||
url: url,
|
||
type: 'DELETE',
|
||
dataType: 'json'
|
||
});
|
||
};
|
||
|
||
this._normalizeQuery = function(queryObj) {
|
||
var self = this;
|
||
var queryInfo = (queryObj && queryObj.toJSON) ? queryObj.toJSON() : _.extend({}, queryObj);
|
||
var out = {
|
||
constant_score: {
|
||
query: {}
|
||
}
|
||
};
|
||
if (!queryInfo.q) {
|
||
out.constant_score.query = {
|
||
match_all: {}
|
||
};
|
||
} else {
|
||
out.constant_score.query = {
|
||
query_string: {
|
||
query: queryInfo.q
|
||
}
|
||
};
|
||
}
|
||
if (queryInfo.filters && queryInfo.filters.length) {
|
||
out.constant_score.filter = {
|
||
and: []
|
||
};
|
||
_.each(queryInfo.filters, function(filter) {
|
||
out.constant_score.filter.and.push(self._convertFilter(filter));
|
||
});
|
||
}
|
||
return out;
|
||
},
|
||
|
||
// convert from Recline sort structure to ES form
|
||
// http://www.elasticsearch.org/guide/reference/api/search/sort.html
|
||
this._normalizeSort = function(sort) {
|
||
var out = _.map(sort, function(sortObj) {
|
||
var _tmp = {};
|
||
var _tmp2 = _.clone(sortObj);
|
||
delete _tmp2['field'];
|
||
_tmp[sortObj.field] = _tmp2;
|
||
return _tmp;
|
||
});
|
||
return out;
|
||
},
|
||
|
||
this._convertFilter = function(filter) {
|
||
var out = {};
|
||
out[filter.type] = {};
|
||
if (filter.type === 'term') {
|
||
out.term[filter.field] = filter.term.toLowerCase();
|
||
} else if (filter.type === 'geo_distance') {
|
||
out.geo_distance[filter.field] = filter.point;
|
||
out.geo_distance.distance = filter.distance;
|
||
out.geo_distance.unit = filter.unit;
|
||
}
|
||
return out;
|
||
},
|
||
|
||
// ### query
|
||
//
|
||
// @return deferred supporting promise API
|
||
this.query = function(queryObj) {
|
||
var esQuery = (queryObj && queryObj.toJSON) ? queryObj.toJSON() : _.extend({}, queryObj);
|
||
esQuery.query = this._normalizeQuery(queryObj);
|
||
delete esQuery.q;
|
||
delete esQuery.filters;
|
||
if (esQuery.sort && esQuery.sort.length > 0) {
|
||
esQuery.sort = this._normalizeSort(esQuery.sort);
|
||
}
|
||
var data = {source: JSON.stringify(esQuery)};
|
||
var url = this.endpoint + '/_search';
|
||
var jqxhr = makeRequest({
|
||
url: url,
|
||
data: data,
|
||
dataType: this.options.dataType
|
||
});
|
||
return jqxhr;
|
||
};
|
||
};
|
||
|
||
|
||
// ## Recline Connectors
|
||
//
|
||
// Requires URL of ElasticSearch endpoint to be specified on the dataset
|
||
// via the url attribute.
|
||
|
||
// ES options which are passed through to `options` on Wrapper (see Wrapper for details)
|
||
my.esOptions = {};
|
||
|
||
// ### fetch
|
||
my.fetch = function(dataset) {
|
||
var es = new my.Wrapper(dataset.url, my.esOptions);
|
||
var dfd = new Deferred();
|
||
es.mapping().done(function(schema) {
|
||
|
||
if (!schema){
|
||
dfd.reject({'message':'Elastic Search did not return a mapping'});
|
||
return;
|
||
}
|
||
|
||
// 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;
|
||
});
|
||
dfd.resolve({
|
||
fields: fieldData
|
||
});
|
||
})
|
||
.fail(function(args) {
|
||
dfd.reject(args);
|
||
});
|
||
return dfd.promise();
|
||
};
|
||
|
||
// ### save
|
||
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 = new Deferred();
|
||
msg = 'Saving more than one item at a time not yet supported';
|
||
alert(msg);
|
||
dfd.reject(msg);
|
||
return dfd.promise();
|
||
}
|
||
if (changes.creates.length > 0) {
|
||
return es.upsert(changes.creates[0]);
|
||
}
|
||
else if (changes.updates.length >0) {
|
||
return es.upsert(changes.updates[0]);
|
||
} else if (changes.deletes.length > 0) {
|
||
return es.remove(changes.deletes[0].id);
|
||
}
|
||
};
|
||
|
||
// ### query
|
||
my.query = function(queryObj, dataset) {
|
||
var dfd = new Deferred();
|
||
var es = new my.Wrapper(dataset.url, my.esOptions);
|
||
var jqxhr = es.query(queryObj);
|
||
jqxhr.done(function(results) {
|
||
var out = {
|
||
total: results.hits.total
|
||
};
|
||
out.hits = _.map(results.hits.hits, function(hit) {
|
||
if (!('id' in hit._source) && hit._id) {
|
||
hit._source.id = hit._id;
|
||
}
|
||
return hit._source;
|
||
});
|
||
if (results.facets) {
|
||
out.facets = results.facets;
|
||
}
|
||
dfd.resolve(out);
|
||
}).fail(function(errorObj) {
|
||
var out = {
|
||
title: 'Failed: ' + errorObj.status + ' code',
|
||
message: errorObj.responseText
|
||
};
|
||
dfd.reject(out);
|
||
});
|
||
return dfd.promise();
|
||
};
|
||
|
||
|
||
// ### makeRequest
|
||
//
|
||
// Just $.ajax but in any headers in the 'headers' attribute of this
|
||
// Backend instance. Example:
|
||
//
|
||
// <pre>
|
||
// var jqxhr = this._makeRequest({
|
||
// url: the-url
|
||
// });
|
||
// </pre>
|
||
var makeRequest = function(data, 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);
|
||
};
|
||
|
||
}(jQuery, this.recline.Backend.ElasticSearch));
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.Backend = this.recline.Backend || {};
|
||
this.recline.Backend.GDocs = this.recline.Backend.GDocs || {};
|
||
|
||
(function(my) {
|
||
"use strict";
|
||
my.__type__ = 'gdocs';
|
||
|
||
// use either jQuery or Underscore Deferred depending on what is available
|
||
var Deferred = (typeof jQuery !== "undefined" && jQuery.Deferred) || _.Deferred;
|
||
|
||
// ## Google spreadsheet backend
|
||
//
|
||
// Fetch data from a Google Docs spreadsheet.
|
||
//
|
||
// Dataset must have a url attribute pointing to the Gdocs or its JSON feed e.g.
|
||
// <pre>
|
||
// var dataset = new recline.Model.Dataset({
|
||
// url: 'https://docs.google.com/spreadsheet/ccc?key=0Aon3JiuouxLUdGlQVDJnbjZRSU1tUUJWOUZXRG53VkE#gid=0'
|
||
// },
|
||
// 'gdocs'
|
||
// );
|
||
//
|
||
// var dataset = new recline.Model.Dataset({
|
||
// url: 'https://spreadsheets.google.com/feeds/list/0Aon3JiuouxLUdDQwZE1JdV94cUd6NWtuZ0IyWTBjLWc/od6/public/values?alt=json'
|
||
// },
|
||
// 'gdocs'
|
||
// );
|
||
// </pre>
|
||
//
|
||
// @return object with two attributes
|
||
//
|
||
// * fields: array of Field objects
|
||
// * records: array of objects for each row
|
||
my.fetch = function(dataset) {
|
||
var dfd = new Deferred();
|
||
var urls = my.getGDocsAPIUrls(dataset.url);
|
||
|
||
// TODO cover it with tests
|
||
// get the spreadsheet title
|
||
(function () {
|
||
var titleDfd = new Deferred();
|
||
|
||
jQuery.getJSON(urls.spreadsheet, function (d) {
|
||
titleDfd.resolve({
|
||
spreadsheetTitle: d.feed.title.$t
|
||
});
|
||
});
|
||
|
||
return titleDfd.promise();
|
||
}()).then(function (response) {
|
||
|
||
// get the actual worksheet data
|
||
jQuery.getJSON(urls.worksheet, function(d) {
|
||
var result = my.parseData(d);
|
||
var fields = _.map(result.fields, function(fieldId) {
|
||
return {id: fieldId};
|
||
});
|
||
|
||
dfd.resolve({
|
||
metadata: {
|
||
title: response.spreadsheetTitle +" :: "+ result.worksheetTitle,
|
||
spreadsheetTitle: response.spreadsheetTitle,
|
||
worksheetTitle : result.worksheetTitle
|
||
},
|
||
records : result.records,
|
||
fields : fields,
|
||
useMemoryStore: true
|
||
});
|
||
});
|
||
});
|
||
|
||
return dfd.promise();
|
||
};
|
||
|
||
// ## 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, options) {
|
||
var options = options || {};
|
||
var colTypes = options.colTypes || {};
|
||
var results = {
|
||
fields : [],
|
||
records: []
|
||
};
|
||
var entries = gdocsSpreadsheet.feed.entry || [];
|
||
var key;
|
||
var colName;
|
||
// percentage values (e.g. 23.3%)
|
||
var rep = /^([\d\.\-]+)\%$/;
|
||
|
||
for(key in entries[0]) {
|
||
// it's barely possible it has inherited keys starting with 'gsx$'
|
||
if(/^gsx/.test(key)) {
|
||
colName = key.substr(4);
|
||
results.fields.push(colName);
|
||
}
|
||
}
|
||
|
||
// converts non numberical values that should be numerical (22.3%[string] -> 0.223[float])
|
||
results.records = _.map(entries, function(entry) {
|
||
var row = {};
|
||
|
||
_.each(results.fields, function(col) {
|
||
var _keyname = 'gsx$' + col;
|
||
var value = entry[_keyname].$t;
|
||
var num;
|
||
|
||
// TODO cover this part of code with test
|
||
// TODO use the regexp only once
|
||
// if labelled as % and value contains %, convert
|
||
if(colTypes[col] === 'percent' && rep.test(value)) {
|
||
num = rep.exec(value)[1];
|
||
value = parseFloat(num) / 100;
|
||
}
|
||
|
||
row[col] = value;
|
||
});
|
||
|
||
return row;
|
||
});
|
||
|
||
results.worksheetTitle = gdocsSpreadsheet.feed.title.$t;
|
||
return results;
|
||
};
|
||
|
||
// Convenience function to get GDocs JSON API Url from standard URL
|
||
my.getGDocsAPIUrls = function(url) {
|
||
// https://docs.google.com/spreadsheet/ccc?key=XXXX#gid=YYY
|
||
var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+)[^#]*(#gid=([\d]+).*)?/;
|
||
var matches = url.match(regex);
|
||
var key;
|
||
var worksheet;
|
||
var urls;
|
||
|
||
if(!!matches) {
|
||
key = matches[1];
|
||
// the gid in url is 0-based and feed url is 1-based
|
||
worksheet = parseInt(matches[3], 10) + 1;
|
||
if (isNaN(worksheet)) {
|
||
worksheet = 1;
|
||
}
|
||
urls = {
|
||
worksheet : 'https://spreadsheets.google.com/feeds/list/'+ key +'/'+ worksheet +'/public/values?alt=json',
|
||
spreadsheet: 'https://spreadsheets.google.com/feeds/worksheets/'+ key +'/public/basic?alt=json'
|
||
};
|
||
}
|
||
else {
|
||
// we assume that it's one of the feeds urls
|
||
key = url.split('/')[5];
|
||
// by default then, take first worksheet
|
||
worksheet = 1;
|
||
urls = {
|
||
worksheet : 'https://spreadsheets.google.com/feeds/list/'+ key +'/'+ worksheet +'/public/values?alt=json',
|
||
spreadsheet: 'https://spreadsheets.google.com/feeds/worksheets/'+ key +'/public/basic?alt=json'
|
||
};
|
||
}
|
||
|
||
return urls;
|
||
};
|
||
}(this.recline.Backend.GDocs));
|
||
this.recline = this.recline || {};
|
||
this.recline.Backend = this.recline.Backend || {};
|
||
this.recline.Backend.Memory = this.recline.Backend.Memory || {};
|
||
|
||
(function(my) {
|
||
"use strict";
|
||
my.__type__ = 'memory';
|
||
|
||
// private data - use either jQuery or Underscore Deferred depending on what is available
|
||
var Deferred = (typeof jQuery !== "undefined" && jQuery.Deferred) || _.Deferred;
|
||
|
||
// ## Data Wrapper
|
||
//
|
||
// Turn a simple array of JS objects into a mini data-store with
|
||
// functionality like querying, faceting, updating (by ID) and deleting (by
|
||
// ID).
|
||
//
|
||
// @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(records, fields) {
|
||
var self = this;
|
||
this.records = records;
|
||
// backwards compatability (in v0.5 records was named data)
|
||
this.data = this.records;
|
||
if (fields) {
|
||
this.fields = fields;
|
||
} else {
|
||
if (records) {
|
||
this.fields = _.map(records[0], function(value, key) {
|
||
return {id: key, type: 'string'};
|
||
});
|
||
}
|
||
}
|
||
|
||
this.update = function(doc) {
|
||
_.each(self.records, function(internalDoc, idx) {
|
||
if(doc.id === internalDoc.id) {
|
||
self.records[idx] = doc;
|
||
}
|
||
});
|
||
};
|
||
|
||
this.remove = function(doc) {
|
||
var newdocs = _.reject(self.records, function(internalDoc) {
|
||
return (doc.id === internalDoc.id);
|
||
});
|
||
this.records = newdocs;
|
||
};
|
||
|
||
this.save = function(changes, dataset) {
|
||
var self = this;
|
||
var dfd = new Deferred();
|
||
// TODO _.each(changes.creates) { ... }
|
||
_.each(changes.updates, function(record) {
|
||
self.update(record);
|
||
});
|
||
_.each(changes.deletes, function(record) {
|
||
self.remove(record);
|
||
});
|
||
dfd.resolve();
|
||
return dfd.promise();
|
||
},
|
||
|
||
this.query = function(queryObj) {
|
||
var dfd = new Deferred();
|
||
var numRows = queryObj.size || this.records.length;
|
||
var start = queryObj.from || 0;
|
||
var results = this.records;
|
||
|
||
results = this._applyFilters(results, queryObj);
|
||
results = this._applyFreeTextQuery(results, queryObj);
|
||
|
||
// TODO: this is not complete sorting!
|
||
// What's wrong is we sort on the *last* entry in the sort list if there are multiple sort criteria
|
||
_.each(queryObj.sort, function(sortObj) {
|
||
var fieldName = sortObj.field;
|
||
results = _.sortBy(results, function(doc) {
|
||
var _out = doc[fieldName];
|
||
return _out;
|
||
});
|
||
if (sortObj.order == 'desc') {
|
||
results.reverse();
|
||
}
|
||
});
|
||
var facets = this.computeFacets(results, queryObj);
|
||
var out = {
|
||
total: results.length,
|
||
hits: results.slice(start, start+numRows),
|
||
facets: facets
|
||
};
|
||
dfd.resolve(out);
|
||
return dfd.promise();
|
||
};
|
||
|
||
// in place filtering
|
||
this._applyFilters = function(results, queryObj) {
|
||
var filters = queryObj.filters;
|
||
// register filters
|
||
var filterFunctions = {
|
||
term : term,
|
||
range : range,
|
||
geo_distance : geo_distance
|
||
};
|
||
var dataParsers = {
|
||
integer: function (e) { return parseFloat(e, 10); },
|
||
'float': function (e) { return parseFloat(e, 10); },
|
||
number: function (e) { return parseFloat(e, 10); },
|
||
string : function (e) { return e.toString(); },
|
||
date : function (e) { return moment(e).valueOf(); },
|
||
datetime : function (e) { return new Date(e).valueOf(); }
|
||
};
|
||
var keyedFields = {};
|
||
_.each(self.fields, function(field) {
|
||
keyedFields[field.id] = field;
|
||
});
|
||
function getDataParser(filter) {
|
||
var fieldType = keyedFields[filter.field].type || 'string';
|
||
return dataParsers[fieldType];
|
||
}
|
||
|
||
// filter records
|
||
return _.filter(results, function (record) {
|
||
var passes = _.map(filters, function (filter) {
|
||
return filterFunctions[filter.type](record, filter);
|
||
});
|
||
|
||
// return only these records that pass all filters
|
||
return _.all(passes, _.identity);
|
||
});
|
||
|
||
// filters definitions
|
||
function term(record, filter) {
|
||
var parse = getDataParser(filter);
|
||
var value = parse(record[filter.field]);
|
||
var term = parse(filter.term);
|
||
|
||
return (value === term);
|
||
}
|
||
|
||
function range(record, filter) {
|
||
var startnull = (filter.start === null || filter.start === '');
|
||
var stopnull = (filter.stop === null || filter.stop === '');
|
||
var parse = getDataParser(filter);
|
||
var value = parse(record[filter.field]);
|
||
var start = parse(filter.start);
|
||
var stop = parse(filter.stop);
|
||
|
||
// if at least one end of range is set do not allow '' to get through
|
||
// note that for strings '' <= {any-character} e.g. '' <= 'a'
|
||
if ((!startnull || !stopnull) && value === '') {
|
||
return false;
|
||
}
|
||
return ((startnull || value >= start) && (stopnull || value <= stop));
|
||
}
|
||
|
||
function geo_distance() {
|
||
// TODO code here
|
||
}
|
||
};
|
||
|
||
// we OR across fields but AND across terms in query string
|
||
this._applyFreeTextQuery = function(results, queryObj) {
|
||
if (queryObj.q) {
|
||
var terms = queryObj.q.split(' ');
|
||
var patterns=_.map(terms, function(term) {
|
||
return new RegExp(term.toLowerCase());
|
||
});
|
||
results = _.filter(results, function(rawdoc) {
|
||
var matches = true;
|
||
_.each(patterns, function(pattern) {
|
||
var foundmatch = false;
|
||
_.each(self.fields, function(field) {
|
||
var value = rawdoc[field.id];
|
||
if ((value !== null) && (value !== undefined)) {
|
||
value = value.toString();
|
||
} else {
|
||
// value can be null (apparently in some cases)
|
||
value = '';
|
||
}
|
||
// TODO regexes?
|
||
foundmatch = foundmatch || (pattern.test(value.toLowerCase()));
|
||
// TODO: early out (once we are true should break to spare unnecessary testing)
|
||
// if (foundmatch) return true;
|
||
});
|
||
matches = matches && foundmatch;
|
||
// TODO: early out (once false should break to spare unnecessary testing)
|
||
// if (!matches) return false;
|
||
});
|
||
return matches;
|
||
});
|
||
}
|
||
return results;
|
||
};
|
||
|
||
this.computeFacets = function(records, queryObj) {
|
||
var facetResults = {};
|
||
if (!queryObj.facets) {
|
||
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 = {};
|
||
});
|
||
// faceting
|
||
_.each(records, function(doc) {
|
||
_.each(queryObj.facets, function(query, facetId) {
|
||
var fieldId = query.terms.field;
|
||
var val = doc[fieldId];
|
||
var tmp = facetResults[facetId];
|
||
if (val) {
|
||
tmp.termsall[val] = tmp.termsall[val] ? tmp.termsall[val] + 1 : 1;
|
||
} else {
|
||
tmp.missing = tmp.missing + 1;
|
||
}
|
||
});
|
||
});
|
||
_.each(queryObj.facets, function(query, facetId) {
|
||
var tmp = facetResults[facetId];
|
||
var terms = _.map(tmp.termsall, function(count, term) {
|
||
return { term: term, count: count };
|
||
});
|
||
tmp.terms = _.sortBy(terms, function(item) {
|
||
// want descending order
|
||
return -item.count;
|
||
});
|
||
tmp.terms = tmp.terms.slice(0, 10);
|
||
});
|
||
return facetResults;
|
||
};
|
||
};
|
||
|
||
}(this.recline.Backend.Memory));
|
||
// This file adds in full array method support in browsers that don't support it
|
||
// see: http://stackoverflow.com/questions/2790001/fixing-javascript-array-functions-in-internet-explorer-indexof-foreach-etc
|
||
|
||
// Add ECMA262-5 Array methods if not supported natively
|
||
if (!('indexOf' in Array.prototype)) {
|
||
Array.prototype.indexOf= function(find, i /*opt*/) {
|
||
if (i===undefined) i= 0;
|
||
if (i<0) i+= this.length;
|
||
if (i<0) i= 0;
|
||
for (var n= this.length; i<n; i++)
|
||
if (i in this && this[i]===find)
|
||
return i;
|
||
return -1;
|
||
};
|
||
}
|
||
if (!('lastIndexOf' in Array.prototype)) {
|
||
Array.prototype.lastIndexOf= function(find, i /*opt*/) {
|
||
if (i===undefined) i= this.length-1;
|
||
if (i<0) i+= this.length;
|
||
if (i>this.length-1) i= this.length-1;
|
||
for (i++; i-->0;) /* i++ because from-argument is sadly inclusive */
|
||
if (i in this && this[i]===find)
|
||
return i;
|
||
return -1;
|
||
};
|
||
}
|
||
if (!('forEach' in Array.prototype)) {
|
||
Array.prototype.forEach= function(action, that /*opt*/) {
|
||
for (var i= 0, n= this.length; i<n; i++)
|
||
if (i in this)
|
||
action.call(that, this[i], i, this);
|
||
};
|
||
}
|
||
if (!('map' in Array.prototype)) {
|
||
Array.prototype.map= function(mapper, that /*opt*/) {
|
||
var other= new Array(this.length);
|
||
for (var i= 0, n= this.length; i<n; i++)
|
||
if (i in this)
|
||
other[i]= mapper.call(that, this[i], i, this);
|
||
return other;
|
||
};
|
||
}
|
||
if (!('filter' in Array.prototype)) {
|
||
Array.prototype.filter= function(filter, that /*opt*/) {
|
||
var other= [], v;
|
||
for (var i=0, n= this.length; i<n; i++)
|
||
if (i in this && filter.call(that, v= this[i], i, this))
|
||
other.push(v);
|
||
return other;
|
||
};
|
||
}
|
||
if (!('every' in Array.prototype)) {
|
||
Array.prototype.every= function(tester, that /*opt*/) {
|
||
for (var i= 0, n= this.length; i<n; i++)
|
||
if (i in this && !tester.call(that, this[i], i, this))
|
||
return false;
|
||
return true;
|
||
};
|
||
}
|
||
if (!('some' in Array.prototype)) {
|
||
Array.prototype.some= function(tester, that /*opt*/) {
|
||
for (var i= 0, n= this.length; i<n; i++)
|
||
if (i in this && tester.call(that, this[i], i, this))
|
||
return true;
|
||
return false;
|
||
};
|
||
}// # Recline Backbone Models
|
||
this.recline = this.recline || {};
|
||
this.recline.Model = this.recline.Model || {};
|
||
|
||
(function(my) {
|
||
"use strict";
|
||
|
||
// use either jQuery or Underscore Deferred depending on what is available
|
||
var Deferred = (typeof jQuery !== "undefined" && jQuery.Deferred) || _.Deferred;
|
||
|
||
// ## <a id="dataset">Dataset</a>
|
||
my.Dataset = Backbone.Model.extend({
|
||
constructor: function Dataset() {
|
||
Backbone.Model.prototype.constructor.apply(this, arguments);
|
||
},
|
||
|
||
// ### initialize
|
||
initialize: function() {
|
||
var self = this;
|
||
_.bindAll(this, 'query');
|
||
this.backend = null;
|
||
if (this.get('backend')) {
|
||
this.backend = this._backendFromString(this.get('backend'));
|
||
} else { // try to guess backend ...
|
||
if (this.get('records')) {
|
||
this.backend = recline.Backend.Memory;
|
||
}
|
||
}
|
||
this.fields = new my.FieldList();
|
||
this.records = new my.RecordList();
|
||
this._changes = {
|
||
deletes: [],
|
||
updates: [],
|
||
creates: []
|
||
};
|
||
this.facets = new my.FacetList();
|
||
this.recordCount = null;
|
||
this.queryState = new my.Query();
|
||
this.queryState.bind('change facet:add', function () {
|
||
self.query(); // We want to call query() without any arguments.
|
||
});
|
||
// store is what we query and save against
|
||
// store will either be the backend or be a memory store if Backend fetch
|
||
// tells us to use memory store
|
||
this._store = this.backend;
|
||
if (this.backend == recline.Backend.Memory) {
|
||
this.fetch();
|
||
}
|
||
},
|
||
|
||
// ### fetch
|
||
//
|
||
// Retrieve dataset and (some) records from the backend.
|
||
fetch: function() {
|
||
var self = this;
|
||
var dfd = new Deferred();
|
||
|
||
if (this.backend !== recline.Backend.Memory) {
|
||
this.backend.fetch(this.toJSON())
|
||
.done(handleResults)
|
||
.fail(function(args) {
|
||
dfd.reject(args);
|
||
});
|
||
} else {
|
||
// special case where we have been given data directly
|
||
handleResults({
|
||
records: this.get('records'),
|
||
fields: this.get('fields'),
|
||
useMemoryStore: true
|
||
});
|
||
}
|
||
|
||
function handleResults(results) {
|
||
var out = self._normalizeRecordsAndFields(results.records, results.fields);
|
||
if (results.useMemoryStore) {
|
||
self._store = new recline.Backend.Memory.Store(out.records, out.fields);
|
||
}
|
||
|
||
self.set(results.metadata);
|
||
self.fields.reset(out.fields);
|
||
self.query()
|
||
.done(function() {
|
||
dfd.resolve(self);
|
||
})
|
||
.fail(function(args) {
|
||
dfd.reject(args);
|
||
});
|
||
}
|
||
|
||
return dfd.promise();
|
||
},
|
||
|
||
// ### _normalizeRecordsAndFields
|
||
//
|
||
// Get a proper set of fields and records from incoming set of fields and records either of which may be null or arrays or objects
|
||
//
|
||
// e.g. fields = ['a', 'b', 'c'] and records = [ [1,2,3] ] =>
|
||
// fields = [ {id: a}, {id: b}, {id: c}], records = [ {a: 1}, {b: 2}, {c: 3}]
|
||
_normalizeRecordsAndFields: function(records, fields) {
|
||
// if no fields get them from records
|
||
if (!fields && records && records.length > 0) {
|
||
// records is array then fields is first row of records ...
|
||
if (records[0] instanceof Array) {
|
||
fields = records[0];
|
||
records = records.slice(1);
|
||
} else {
|
||
fields = _.map(_.keys(records[0]), function(key) {
|
||
return {id: key};
|
||
});
|
||
}
|
||
}
|
||
|
||
// fields is an array of strings (i.e. list of field headings/ids)
|
||
if (fields && fields.length > 0 && (fields[0] === null || typeof(fields[0]) != 'object')) {
|
||
// Rename duplicate fieldIds as each field name needs to be
|
||
// unique.
|
||
var seen = {};
|
||
fields = _.map(fields, function(field, index) {
|
||
if (field === null) {
|
||
field = '';
|
||
} else {
|
||
field = field.toString();
|
||
}
|
||
// cannot use trim as not supported by IE7
|
||
var fieldId = field.replace(/^\s+|\s+$/g, '');
|
||
if (fieldId === '') {
|
||
fieldId = '_noname_';
|
||
field = fieldId;
|
||
}
|
||
while (fieldId in seen) {
|
||
seen[field] += 1;
|
||
fieldId = field + seen[field];
|
||
}
|
||
if (!(field in seen)) {
|
||
seen[field] = 0;
|
||
}
|
||
// TODO: decide whether to keep original name as label ...
|
||
// return { id: fieldId, label: field || fieldId }
|
||
return { id: fieldId };
|
||
});
|
||
}
|
||
// records is provided as arrays so need to zip together with fields
|
||
// NB: this requires you to have fields to match arrays
|
||
if (records && records.length > 0 && records[0] instanceof Array) {
|
||
records = _.map(records, function(doc) {
|
||
var tmp = {};
|
||
_.each(fields, function(field, idx) {
|
||
tmp[field.id] = doc[idx];
|
||
});
|
||
return tmp;
|
||
});
|
||
}
|
||
return {
|
||
fields: fields,
|
||
records: records
|
||
};
|
||
},
|
||
|
||
save: function() {
|
||
var self = this;
|
||
// TODO: need to reset the changes ...
|
||
return this._store.save(this._changes, this.toJSON());
|
||
},
|
||
|
||
// ### query
|
||
//
|
||
// AJAX method with promise API to get records from the backend.
|
||
//
|
||
// It will query based on current query state (given by this.queryState)
|
||
// updated by queryObj (if provided).
|
||
//
|
||
// Resulting RecordList are used to reset this.records and are
|
||
// also returned.
|
||
query: function(queryObj) {
|
||
var self = this;
|
||
var dfd = new Deferred();
|
||
this.trigger('query:start');
|
||
|
||
if (queryObj) {
|
||
this.queryState.set(queryObj, {silent: true});
|
||
}
|
||
var actualQuery = this.queryState.toJSON();
|
||
|
||
this._store.query(actualQuery, this.toJSON())
|
||
.done(function(queryResult) {
|
||
self._handleQueryResult(queryResult);
|
||
self.trigger('query:done');
|
||
dfd.resolve(self.records);
|
||
})
|
||
.fail(function(args) {
|
||
self.trigger('query:fail', args);
|
||
dfd.reject(args);
|
||
});
|
||
return dfd.promise();
|
||
},
|
||
|
||
_handleQueryResult: function(queryResult) {
|
||
var self = this;
|
||
self.recordCount = queryResult.total;
|
||
var docs = _.map(queryResult.hits, function(hit) {
|
||
var _doc = new my.Record(hit);
|
||
_doc.fields = self.fields;
|
||
_doc.bind('change', function(doc) {
|
||
self._changes.updates.push(doc.toJSON());
|
||
});
|
||
_doc.bind('destroy', function(doc) {
|
||
self._changes.deletes.push(doc.toJSON());
|
||
});
|
||
return _doc;
|
||
});
|
||
self.records.reset(docs);
|
||
if (queryResult.facets) {
|
||
var facets = _.map(queryResult.facets, function(facetResult, facetId) {
|
||
facetResult.id = facetId;
|
||
return new my.Facet(facetResult);
|
||
});
|
||
self.facets.reset(facets);
|
||
}
|
||
},
|
||
|
||
toTemplateJSON: function() {
|
||
var data = this.toJSON();
|
||
data.recordCount = this.recordCount;
|
||
data.fields = this.fields.toJSON();
|
||
return data;
|
||
},
|
||
|
||
// ### getFieldsSummary
|
||
//
|
||
// Get a summary for each field in the form of a `Facet`.
|
||
//
|
||
// @return null as this is async function. Provides deferred/promise interface.
|
||
getFieldsSummary: function() {
|
||
var self = this;
|
||
var query = new my.Query();
|
||
query.set({size: 0});
|
||
this.fields.each(function(field) {
|
||
query.addFacet(field.id);
|
||
});
|
||
var dfd = new Deferred();
|
||
this._store.query(query.toJSON(), this.toJSON()).done(function(queryResult) {
|
||
if (queryResult.facets) {
|
||
_.each(queryResult.facets, function(facetResult, facetId) {
|
||
facetResult.id = facetId;
|
||
var facet = new my.Facet(facetResult);
|
||
// TODO: probably want replace rather than reset (i.e. just replace the facet with this id)
|
||
self.fields.get(facetId).facets.reset(facet);
|
||
});
|
||
}
|
||
dfd.resolve(queryResult);
|
||
});
|
||
return dfd.promise();
|
||
},
|
||
|
||
// Deprecated (as of v0.5) - use record.summary()
|
||
recordSummary: function(record) {
|
||
return record.summary();
|
||
},
|
||
|
||
// ### _backendFromString(backendString)
|
||
//
|
||
// Look up a backend module from a backend string (look in recline.Backend)
|
||
_backendFromString: function(backendString) {
|
||
var backend = null;
|
||
if (recline && recline.Backend) {
|
||
_.each(_.keys(recline.Backend), function(name) {
|
||
if (name.toLowerCase() === backendString.toLowerCase()) {
|
||
backend = recline.Backend[name];
|
||
}
|
||
});
|
||
}
|
||
return backend;
|
||
}
|
||
});
|
||
|
||
|
||
// ## <a id="record">A Record</a>
|
||
//
|
||
// A single record (or row) in the dataset
|
||
my.Record = Backbone.Model.extend({
|
||
constructor: function Record() {
|
||
Backbone.Model.prototype.constructor.apply(this, arguments);
|
||
},
|
||
|
||
// ### initialize
|
||
//
|
||
// Create a Record
|
||
//
|
||
// You usually will not do this directly but will have records created by
|
||
// Dataset e.g. in query method
|
||
//
|
||
// Certain methods require presence of a fields attribute (identical to that on Dataset)
|
||
initialize: function() {
|
||
_.bindAll(this, 'getFieldValue');
|
||
},
|
||
|
||
// ### getFieldValue
|
||
//
|
||
// For the provided Field get the corresponding rendered computed data value
|
||
// for this record.
|
||
//
|
||
// NB: if field is undefined a default '' value will be returned
|
||
getFieldValue: function(field) {
|
||
var val = this.getFieldValueUnrendered(field);
|
||
if (field && !_.isUndefined(field.renderer)) {
|
||
val = field.renderer(val, field, this.toJSON());
|
||
}
|
||
return val;
|
||
},
|
||
|
||
// ### getFieldValueUnrendered
|
||
//
|
||
// For the provided Field get the corresponding computed data value
|
||
// for this record.
|
||
//
|
||
// NB: if field is undefined a default '' value will be returned
|
||
getFieldValueUnrendered: function(field) {
|
||
if (!field) {
|
||
return '';
|
||
}
|
||
var val = this.get(field.id);
|
||
if (field.deriver) {
|
||
val = field.deriver(val, field, this);
|
||
}
|
||
return val;
|
||
},
|
||
|
||
// ### summary
|
||
//
|
||
// Get a simple html summary of this record in form of key/value list
|
||
summary: function(record) {
|
||
var self = this;
|
||
var html = '<div class="recline-record-summary">';
|
||
this.fields.each(function(field) {
|
||
if (field.id != 'id') {
|
||
html += '<div class="' + field.id + '"><strong>' + field.get('label') + '</strong>: ' + self.getFieldValue(field) + '</div>';
|
||
}
|
||
});
|
||
html += '</div>';
|
||
return html;
|
||
},
|
||
|
||
// Override Backbone save, fetch and destroy so they do nothing
|
||
// Instead, Dataset object that created this Record should take care of
|
||
// handling these changes (discovery will occur via event notifications)
|
||
// WARNING: these will not persist *unless* you call save on Dataset
|
||
fetch: function() {},
|
||
save: function() {},
|
||
destroy: function() { this.trigger('destroy', this); }
|
||
});
|
||
|
||
|
||
// ## A Backbone collection of Records
|
||
my.RecordList = Backbone.Collection.extend({
|
||
constructor: function RecordList() {
|
||
Backbone.Collection.prototype.constructor.apply(this, arguments);
|
||
},
|
||
model: my.Record
|
||
});
|
||
|
||
|
||
// ## <a id="field">A Field (aka Column) on a Dataset</a>
|
||
my.Field = Backbone.Model.extend({
|
||
constructor: function Field() {
|
||
Backbone.Model.prototype.constructor.apply(this, arguments);
|
||
},
|
||
// ### defaults - define default values
|
||
defaults: {
|
||
label: null,
|
||
type: 'string',
|
||
format: null,
|
||
is_derived: false
|
||
},
|
||
// ### initialize
|
||
//
|
||
// @param {Object} data: standard Backbone model attributes
|
||
//
|
||
// @param {Object} options: renderer and/or deriver functions.
|
||
initialize: function(data, options) {
|
||
// if a hash not passed in the first argument throw error
|
||
if ('0' in data) {
|
||
throw new Error('Looks like you did not pass a proper hash with id to Field constructor');
|
||
}
|
||
if (this.attributes.label === null) {
|
||
this.set({label: this.id});
|
||
}
|
||
if (this.attributes.type.toLowerCase() in this._typeMap) {
|
||
this.attributes.type = this._typeMap[this.attributes.type.toLowerCase()];
|
||
}
|
||
if (options) {
|
||
this.renderer = options.renderer;
|
||
this.deriver = options.deriver;
|
||
}
|
||
if (!this.renderer) {
|
||
this.renderer = this.defaultRenderers[this.get('type')];
|
||
}
|
||
this.facets = new my.FacetList();
|
||
},
|
||
_typeMap: {
|
||
'text': 'string',
|
||
'double': 'number',
|
||
'float': 'number',
|
||
'numeric': 'number',
|
||
'int': 'integer',
|
||
'datetime': 'date-time',
|
||
'bool': 'boolean',
|
||
'timestamp': 'date-time',
|
||
'json': 'object'
|
||
},
|
||
defaultRenderers: {
|
||
object: function(val, field, doc) {
|
||
return JSON.stringify(val);
|
||
},
|
||
geo_point: function(val, field, doc) {
|
||
return JSON.stringify(val);
|
||
},
|
||
'number': function(val, field, doc) {
|
||
var format = field.get('format');
|
||
if (format === 'percentage') {
|
||
return val + '%';
|
||
}
|
||
return val;
|
||
},
|
||
'string': function(val, field, doc) {
|
||
var format = field.get('format');
|
||
if (format === 'markdown') {
|
||
if (typeof Showdown !== 'undefined') {
|
||
var showdown = new Showdown.converter();
|
||
out = showdown.makeHtml(val);
|
||
return out;
|
||
} else {
|
||
return val;
|
||
}
|
||
} else if (format == 'plain') {
|
||
return val;
|
||
} else {
|
||
// as this is the default and default type is string may get things
|
||
// here that are not actually strings
|
||
if (val && typeof val === 'string') {
|
||
val = val.replace(/(https?:\/\/[^ ]+)/g, '<a href="$1">$1</a>');
|
||
}
|
||
return val;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
my.FieldList = Backbone.Collection.extend({
|
||
constructor: function FieldList() {
|
||
Backbone.Collection.prototype.constructor.apply(this, arguments);
|
||
},
|
||
model: my.Field
|
||
});
|
||
|
||
// ## <a id="query">Query</a>
|
||
my.Query = Backbone.Model.extend({
|
||
constructor: function Query() {
|
||
Backbone.Model.prototype.constructor.apply(this, arguments);
|
||
},
|
||
defaults: function() {
|
||
return {
|
||
size: 100,
|
||
from: 0,
|
||
q: '',
|
||
facets: {},
|
||
filters: []
|
||
};
|
||
},
|
||
_filterTemplates: {
|
||
term: {
|
||
type: 'term',
|
||
// TODO do we need this attribute here?
|
||
field: '',
|
||
term: ''
|
||
},
|
||
range: {
|
||
type: 'range',
|
||
start: '',
|
||
stop: ''
|
||
},
|
||
geo_distance: {
|
||
type: 'geo_distance',
|
||
distance: 10,
|
||
unit: 'km',
|
||
point: {
|
||
lon: 0,
|
||
lat: 0
|
||
}
|
||
}
|
||
},
|
||
// ### addFilter(filter)
|
||
//
|
||
// Add a new filter specified by the filter hash and append to the list of filters
|
||
//
|
||
// @param filter an object specifying the filter - see _filterTemplates for examples. If only type is provided will generate a filter by cloning _filterTemplates
|
||
addFilter: function(filter) {
|
||
// crude deep copy
|
||
var ourfilter = JSON.parse(JSON.stringify(filter));
|
||
// not fully specified so use template and over-write
|
||
if (_.keys(filter).length <= 3) {
|
||
ourfilter = _.defaults(ourfilter, this._filterTemplates[filter.type]);
|
||
}
|
||
var filters = this.get('filters');
|
||
filters.push(ourfilter);
|
||
this.trigger('change:filters:new-blank');
|
||
},
|
||
updateFilter: function(index, value) {
|
||
},
|
||
// ### removeFilter
|
||
//
|
||
// Remove a filter from filters at index filterIndex
|
||
removeFilter: function(filterIndex) {
|
||
var filters = this.get('filters');
|
||
filters.splice(filterIndex, 1);
|
||
this.set({filters: filters});
|
||
this.trigger('change');
|
||
},
|
||
// ### addFacet
|
||
//
|
||
// Add a Facet to this query
|
||
//
|
||
// See <http://www.elasticsearch.org/guide/reference/api/search/facets/>
|
||
addFacet: function(fieldId) {
|
||
var facets = this.get('facets');
|
||
// Assume id and fieldId should be the same (TODO: this need not be true if we want to add two different type of facets on same field)
|
||
if (_.contains(_.keys(facets), fieldId)) {
|
||
return;
|
||
}
|
||
facets[fieldId] = {
|
||
terms: { field: fieldId }
|
||
};
|
||
this.set({facets: facets}, {silent: true});
|
||
this.trigger('facet:add', this);
|
||
},
|
||
addHistogramFacet: function(fieldId) {
|
||
var facets = this.get('facets');
|
||
facets[fieldId] = {
|
||
date_histogram: {
|
||
field: fieldId,
|
||
interval: 'day'
|
||
}
|
||
};
|
||
this.set({facets: facets}, {silent: true});
|
||
this.trigger('facet:add', this);
|
||
}
|
||
});
|
||
|
||
|
||
// ## <a id="facet">A Facet (Result)</a>
|
||
my.Facet = Backbone.Model.extend({
|
||
constructor: function Facet() {
|
||
Backbone.Model.prototype.constructor.apply(this, arguments);
|
||
},
|
||
defaults: function() {
|
||
return {
|
||
_type: 'terms',
|
||
total: 0,
|
||
other: 0,
|
||
missing: 0,
|
||
terms: []
|
||
};
|
||
}
|
||
});
|
||
|
||
// ## A Collection/List of Facets
|
||
my.FacetList = Backbone.Collection.extend({
|
||
constructor: function FacetList() {
|
||
Backbone.Collection.prototype.constructor.apply(this, arguments);
|
||
},
|
||
model: my.Facet
|
||
});
|
||
|
||
// ## Object State
|
||
//
|
||
// Convenience Backbone model for storing (configuration) state of objects like Views.
|
||
my.ObjectState = Backbone.Model.extend({
|
||
});
|
||
|
||
|
||
// ## Backbone.sync
|
||
//
|
||
// 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);
|
||
};
|
||
|
||
}(this.recline.Model));
|
||
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
// ## 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: ' \
|
||
<div class="recline-flot"> \
|
||
<div class="panel graph" style="display: block;"> \
|
||
<div class="js-temp-notice alert alert-block"> \
|
||
<h3 class="alert-heading">Hey there!</h3> \
|
||
<p>There\'s no graph here yet because we don\'t know what fields you\'d like to see plotted.</p> \
|
||
<p>Please tell us by <strong>using the menu on the right</strong> and a graph will automatically appear.</p> \
|
||
</div> \
|
||
</div> \
|
||
</div> \
|
||
',
|
||
|
||
initialize: function(options) {
|
||
var self = this;
|
||
this.graphColors = ["#edc240", "#afd8f8", "#cb4b4b", "#4da74d", "#9440ed"];
|
||
|
||
_.bindAll(this, 'render', 'redraw', '_toolTip', '_xaxisLabel');
|
||
this.needToRedraw = false;
|
||
this.listenTo(this.model, 'change', this.render);
|
||
this.listenTo(this.model.fields, 'reset add', this.render);
|
||
this.listenTo(this.model.records, 'reset add', 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.listenTo(this.editor.state, '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;
|
||
},
|
||
|
||
remove: function () {
|
||
this.editor.remove();
|
||
Backbone.View.prototype.remove.apply(this, arguments);
|
||
},
|
||
|
||
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);
|
||
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')) {
|
||
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-flot-tooltip").remove();
|
||
|
||
var x = item.datapoint[0].toFixed(2),
|
||
y = item.datapoint[1].toFixed(2);
|
||
|
||
if (this.state.attributes.graphType === 'bars') {
|
||
x = item.datapoint[1].toFixed(2),
|
||
y = item.datapoint[0].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 - 10;
|
||
} else if (this.state.attributes.graphType === 'columns') {
|
||
xLocation = item.pageX + 15;
|
||
yLocation = item.pageY;
|
||
} else {
|
||
xLocation = item.pageX + 10;
|
||
yLocation = item.pageY - 20;
|
||
}
|
||
|
||
$('<div id="recline-flot-tooltip">' + content + '</div>').css({
|
||
top: yLocation,
|
||
left: xLocation
|
||
}).appendTo("body").fadeIn(200);
|
||
}
|
||
} else {
|
||
$("#recline-flot-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.xvaluesAreIndex) {
|
||
x = parseInt(x, 10);
|
||
// HACK: deal with bar graph style cases where x-axis items were strings
|
||
// In this case x at this point is the index of the item in the list of
|
||
// records not its actual x-axis value
|
||
x = this.model.records.models[x].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 (self.state.attributes.graphType !== 'bars' && label.length > 10) {
|
||
label = label.slice(0, 10) + "...";
|
||
}
|
||
|
||
return label;
|
||
};
|
||
|
||
var xaxis = {};
|
||
xaxis.tickFormatter = tickFormatter;
|
||
|
||
// for labels case we only want ticks at the label intervals
|
||
// HACK: however we also get this case with Date fields. In that case we
|
||
// could have a lot of values and so we limit to max 15 (we assume)
|
||
if (this.xvaluesAreIndex) {
|
||
var numTicks = Math.min(this.model.records.length, 15);
|
||
var increment = this.model.records.length / numTicks;
|
||
var ticks = [];
|
||
for (var i=0; i<numTicks; i++) {
|
||
ticks.push(parseInt(i*increment, 10));
|
||
}
|
||
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;
|
||
self.xvaluesAreIndex = false;
|
||
var series = [];
|
||
_.each(this.state.attributes.series, function(field) {
|
||
var points = [];
|
||
var fieldLabel = self.model.fields.get(field).get('label');
|
||
_.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) {
|
||
self.xvaluesAreIndex = true;
|
||
x = index;
|
||
} else if (typeof x === 'string') {
|
||
x = parseFloat(x);
|
||
if (isNaN(x)) { // assume this is a string label
|
||
x = index;
|
||
self.xvaluesAreIndex = true;
|
||
}
|
||
}
|
||
|
||
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: fieldLabel,
|
||
hoverable: true
|
||
});
|
||
});
|
||
return series;
|
||
}
|
||
});
|
||
|
||
my.FlotControls = Backbone.View.extend({
|
||
className: "editor",
|
||
template: ' \
|
||
<div class="editor"> \
|
||
<form class="form-stacked"> \
|
||
<div class="clearfix"> \
|
||
<label>Graph Type</label> \
|
||
<div class="input editor-type"> \
|
||
<select> \
|
||
<option value="lines-and-points">Lines and Points</option> \
|
||
<option value="lines">Lines</option> \
|
||
<option value="points">Points</option> \
|
||
<option value="bars">Bars</option> \
|
||
<option value="columns">Columns</option> \
|
||
</select> \
|
||
</div> \
|
||
<label>Group Column (Axis 1)</label> \
|
||
<div class="input editor-group"> \
|
||
<select> \
|
||
<option value="">Please choose ...</option> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
</div> \
|
||
<div class="editor-series-group"> \
|
||
</div> \
|
||
</div> \
|
||
<div class="editor-buttons"> \
|
||
<button class="btn editor-add">Add Series</button> \
|
||
</div> \
|
||
<div class="editor-buttons editor-submit" comment="hidden temporarily" style="display: none;"> \
|
||
<button class="editor-save">Save</button> \
|
||
<input type="hidden" class="editor-id" value="chart-1" /> \
|
||
</div> \
|
||
</form> \
|
||
</div> \
|
||
',
|
||
templateSeriesEditor: ' \
|
||
<div class="editor-series js-series-{{seriesIndex}}"> \
|
||
<label>Series <span>{{seriesName}} (Axis 2)</span> \
|
||
[<a href="#remove" class="action-remove-series">Remove</a>] \
|
||
</label> \
|
||
<div class="input"> \
|
||
<select> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
</div> \
|
||
</div> \
|
||
',
|
||
events: {
|
||
'change form select': 'onEditorSubmit',
|
||
'click .editor-add': '_onAddSeries',
|
||
'click .action-remove-series': 'removeSeries'
|
||
},
|
||
|
||
initialize: function(options) {
|
||
var self = this;
|
||
_.bindAll(this, 'render');
|
||
this.listenTo(this.model.fields, 'reset 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 = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
this.recline.View.Graph = this.recline.View.Flot;
|
||
this.recline.View.GraphControls = this.recline.View.FlotControls;
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
// ## (Data) Grid Dataset View
|
||
//
|
||
// Provides a tabular view on a Dataset.
|
||
//
|
||
// Initialize it with a `recline.Model.Dataset`.
|
||
my.Grid = Backbone.View.extend({
|
||
tagName: "div",
|
||
className: "recline-grid-container",
|
||
|
||
initialize: function(modelEtc) {
|
||
var self = this;
|
||
_.bindAll(this, 'render', 'onHorizontalScroll');
|
||
this.listenTo(this.model.records, 'add reset remove', this.render);
|
||
this.tempState = {};
|
||
var state = _.extend({
|
||
hiddenFields: []
|
||
}, modelEtc.state
|
||
);
|
||
this.state = new recline.Model.ObjectState(state);
|
||
},
|
||
|
||
events: {
|
||
// does not work here so done at end of render function
|
||
// 'scroll .recline-grid tbody': 'onHorizontalScroll'
|
||
},
|
||
|
||
// ======================================================
|
||
// Column and row menus
|
||
|
||
setColumnSort: function(order) {
|
||
var sort = [{}];
|
||
sort[0][this.tempState.currentColumn] = {order: order};
|
||
this.model.query({sort: sort});
|
||
},
|
||
|
||
hideColumn: function() {
|
||
var hiddenFields = this.state.get('hiddenFields');
|
||
hiddenFields.push(this.tempState.currentColumn);
|
||
this.state.set({hiddenFields: hiddenFields});
|
||
// change event not being triggered (because it is an array?) so trigger manually
|
||
this.state.trigger('change');
|
||
this.render();
|
||
},
|
||
|
||
showColumn: function(e) {
|
||
var hiddenFields = _.without(this.state.get('hiddenFields'), $(e.target).data('column'));
|
||
this.state.set({hiddenFields: hiddenFields});
|
||
this.render();
|
||
},
|
||
|
||
onHorizontalScroll: function(e) {
|
||
var currentScroll = $(e.target).scrollLeft();
|
||
this.$el.find('.recline-grid thead tr').scrollLeft(currentScroll);
|
||
},
|
||
|
||
// ======================================================
|
||
// #### Templating
|
||
template: ' \
|
||
<div class="table-container"> \
|
||
<table class="recline-grid table-striped table-condensed" cellspacing="0"> \
|
||
<thead class="fixed-header"> \
|
||
<tr> \
|
||
{{#fields}} \
|
||
<th class="column-header {{#hidden}}hidden{{/hidden}}" data-field="{{id}}" style="width: {{width}}px; max-width: {{width}}px; min-width: {{width}}px;" title="{{label}}"> \
|
||
<span class="column-header-name">{{label}}</span> \
|
||
</th> \
|
||
{{/fields}} \
|
||
<th class="last-header" style="width: {{lastHeaderWidth}}px; max-width: {{lastHeaderWidth}}px; min-width: {{lastHeaderWidth}}px; padding: 0; margin: 0;"></th> \
|
||
</tr> \
|
||
</thead> \
|
||
<tbody class="scroll-content"></tbody> \
|
||
</table> \
|
||
</div> \
|
||
',
|
||
|
||
toTemplateJSON: function() {
|
||
var self = this;
|
||
var modelData = this.model.toJSON();
|
||
modelData.notEmpty = ( this.fields.length > 0 );
|
||
// TODO: move this sort of thing into a toTemplateJSON method on Dataset?
|
||
modelData.fields = this.fields.map(function(field) {
|
||
return field.toJSON();
|
||
});
|
||
// last header width = scroll bar - border (2px) */
|
||
modelData.lastHeaderWidth = this.scrollbarDimensions.width - 2;
|
||
return modelData;
|
||
},
|
||
render: function() {
|
||
var self = this;
|
||
this.fields = new recline.Model.FieldList(this.model.fields.filter(function(field) {
|
||
return _.indexOf(self.state.get('hiddenFields'), field.id) == -1;
|
||
}));
|
||
|
||
this.scrollbarDimensions = this.scrollbarDimensions || this._scrollbarSize(); // skip measurement if already have dimensions
|
||
var numFields = this.fields.length;
|
||
// compute field widths (-20 for first menu col + 10px for padding on each col and finally 16px for the scrollbar)
|
||
var fullWidth = self.$el.width() - 20 - 10 * numFields - this.scrollbarDimensions.width;
|
||
var width = parseInt(Math.max(50, fullWidth / numFields), 10);
|
||
// if columns extend outside viewport then remainder is 0
|
||
var remainder = Math.max(fullWidth - numFields * width,0);
|
||
this.fields.each(function(field, idx) {
|
||
// add the remainder to the first field width so we make up full col
|
||
if (idx === 0) {
|
||
field.set({width: width+remainder});
|
||
} else {
|
||
field.set({width: width});
|
||
}
|
||
});
|
||
var htmls = Mustache.render(this.template, this.toTemplateJSON());
|
||
this.$el.html(htmls);
|
||
this.model.records.forEach(function(doc) {
|
||
var tr = $('<tr />');
|
||
self.$el.find('tbody').append(tr);
|
||
var newView = new my.GridRow({
|
||
model: doc,
|
||
el: tr,
|
||
fields: self.fields
|
||
});
|
||
newView.render();
|
||
});
|
||
// hide extra header col if no scrollbar to avoid unsightly overhang
|
||
var $tbody = this.$el.find('tbody')[0];
|
||
if ($tbody.scrollHeight <= $tbody.offsetHeight) {
|
||
this.$el.find('th.last-header').hide();
|
||
}
|
||
this.$el.find('.recline-grid').toggleClass('no-hidden', (self.state.get('hiddenFields').length === 0));
|
||
this.$el.find('.recline-grid tbody').scroll(this.onHorizontalScroll);
|
||
return this;
|
||
},
|
||
|
||
// ### _scrollbarSize
|
||
//
|
||
// Measure width of a vertical scrollbar and height of a horizontal scrollbar.
|
||
//
|
||
// @return: { width: pixelWidth, height: pixelHeight }
|
||
_scrollbarSize: function() {
|
||
var $c = $("<div style='position:absolute; top:-10000px; left:-10000px; width:100px; height:100px; overflow:scroll;'></div>").appendTo("body");
|
||
var dim = { width: $c.width() - $c[0].clientWidth + 1, height: $c.height() - $c[0].clientHeight };
|
||
$c.remove();
|
||
return dim;
|
||
}
|
||
});
|
||
|
||
// ## GridRow View for rendering an individual record.
|
||
//
|
||
// Since we want this to update in place it is up to creator to provider the element to attach to.
|
||
//
|
||
// In addition you *must* pass in a FieldList in the constructor options. This should be list of fields for the Grid.
|
||
//
|
||
// Example:
|
||
//
|
||
// <pre>
|
||
// var row = new GridRow({
|
||
// model: dataset-record,
|
||
// el: dom-element,
|
||
// fields: mydatasets.fields // a FieldList object
|
||
// });
|
||
// </pre>
|
||
my.GridRow = Backbone.View.extend({
|
||
initialize: function(initData) {
|
||
_.bindAll(this, 'render');
|
||
this._fields = initData.fields;
|
||
this.listenTo(this.model, 'change', this.render);
|
||
},
|
||
|
||
template: ' \
|
||
{{#cells}} \
|
||
<td data-field="{{field}}" style="width: {{width}}px; max-width: {{width}}px; min-width: {{width}}px;"> \
|
||
<div class="data-table-cell-content"> \
|
||
<a href="javascript:{}" class="data-table-cell-edit" title="Edit this cell"> </a> \
|
||
<div class="data-table-cell-value">{{{value}}}</div> \
|
||
</div> \
|
||
</td> \
|
||
{{/cells}} \
|
||
',
|
||
events: {
|
||
'click .data-table-cell-edit': 'onEditClick',
|
||
'click .data-table-cell-editor .okButton': 'onEditorOK',
|
||
'click .data-table-cell-editor .cancelButton': 'onEditorCancel'
|
||
},
|
||
|
||
toTemplateJSON: function() {
|
||
var self = this;
|
||
var doc = this.model;
|
||
var cellData = this._fields.map(function(field) {
|
||
return {
|
||
field: field.id,
|
||
width: field.get('width'),
|
||
value: doc.getFieldValue(field)
|
||
};
|
||
});
|
||
return { id: this.id, cells: cellData };
|
||
},
|
||
|
||
render: function() {
|
||
this.$el.attr('data-id', this.model.id);
|
||
var html = Mustache.render(this.template, this.toTemplateJSON());
|
||
this.$el.html(html);
|
||
return this;
|
||
},
|
||
|
||
// ===================
|
||
// Cell Editor methods
|
||
|
||
cellEditorTemplate: ' \
|
||
<div class="menu-container data-table-cell-editor"> \
|
||
<textarea class="data-table-cell-editor-editor" bind="textarea">{{value}}</textarea> \
|
||
<div id="data-table-cell-editor-actions"> \
|
||
<div class="data-table-cell-editor-action"> \
|
||
<button class="okButton btn primary">Update</button> \
|
||
<button class="cancelButton btn danger">Cancel</button> \
|
||
</div> \
|
||
</div> \
|
||
</div> \
|
||
',
|
||
|
||
onEditClick: function(e) {
|
||
var editing = this.$el.find('.data-table-cell-editor-editor');
|
||
if (editing.length > 0) {
|
||
editing.parents('.data-table-cell-value').html(editing.text()).siblings('.data-table-cell-edit').removeClass("hidden");
|
||
}
|
||
$(e.target).addClass("hidden");
|
||
var cell = $(e.target).siblings('.data-table-cell-value');
|
||
cell.data("previousContents", cell.text());
|
||
var templated = Mustache.render(this.cellEditorTemplate, {value: cell.text()});
|
||
cell.html(templated);
|
||
},
|
||
|
||
onEditorOK: function(e) {
|
||
var self = this;
|
||
var cell = $(e.target);
|
||
var rowId = cell.parents('tr').attr('data-id');
|
||
var field = cell.parents('td').attr('data-field');
|
||
var newValue = cell.parents('.data-table-cell-editor').find('.data-table-cell-editor-editor').val();
|
||
var newData = {};
|
||
newData[field] = newValue;
|
||
this.model.set(newData);
|
||
this.trigger('recline:flash', {message: "Updating row...", loader: true});
|
||
this.model.save().then(function(response) {
|
||
this.trigger('recline:flash', {message: "Row updated successfully", category: 'success'});
|
||
})
|
||
.fail(function() {
|
||
this.trigger('recline:flash', {
|
||
message: 'Error saving row',
|
||
category: 'error',
|
||
persist: true
|
||
});
|
||
});
|
||
},
|
||
|
||
onEditorCancel: function(e) {
|
||
var cell = $(e.target).parents('.data-table-cell-value');
|
||
cell.html(cell.data('previousContents')).siblings('.data-table-cell-edit').removeClass("hidden");
|
||
}
|
||
});
|
||
|
||
})(jQuery, recline.View);
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
// ## Map view for a Dataset using Leaflet mapping library.
|
||
//
|
||
// This view allows to plot gereferenced records on a map. The location
|
||
// information can be provided in 2 ways:
|
||
//
|
||
// 1. Via a single field. This field must be either a geo_point or
|
||
// [GeoJSON](http://geojson.org) object
|
||
// 2. Via two fields with latitude and longitude coordinates.
|
||
//
|
||
// Which fields in the data these correspond to can be configured via the state
|
||
// (and are guessed if no info is provided).
|
||
//
|
||
// Initialization arguments are as standard for Dataset Views. State object may
|
||
// have the following (optional) configuration options:
|
||
//
|
||
// <pre>
|
||
// {
|
||
// // geomField if specified will be used in preference to lat/lon
|
||
// geomField: {id of field containing geometry in the dataset}
|
||
// lonField: {id of field containing longitude in the dataset}
|
||
// latField: {id of field containing latitude in the dataset}
|
||
// autoZoom: true,
|
||
// // use cluster support
|
||
// cluster: false
|
||
// }
|
||
// </pre>
|
||
//
|
||
// Useful attributes to know about (if e.g. customizing)
|
||
//
|
||
// * map: the Leaflet map (L.Map)
|
||
// * features: Leaflet GeoJSON layer containing all the features (L.GeoJSON)
|
||
my.Map = Backbone.View.extend({
|
||
template: ' \
|
||
<div class="recline-map"> \
|
||
<div class="panel map"></div> \
|
||
</div> \
|
||
',
|
||
|
||
// These are the default (case-insensitive) names of field that are used if found.
|
||
// If not found, the user will need to define the fields via the editor.
|
||
latitudeFieldNames: ['lat','latitude'],
|
||
longitudeFieldNames: ['lon','longitude'],
|
||
geometryFieldNames: ['geojson', 'geom','the_geom','geometry','spatial','location', 'geo', 'lonlat'],
|
||
|
||
initialize: function(options) {
|
||
var self = this;
|
||
this.visible = true;
|
||
this.mapReady = false;
|
||
// this will be the Leaflet L.Map object (setup below)
|
||
this.map = null;
|
||
|
||
var stateData = _.extend({
|
||
geomField: null,
|
||
lonField: null,
|
||
latField: null,
|
||
autoZoom: true,
|
||
cluster: false
|
||
},
|
||
options.state
|
||
);
|
||
this.state = new recline.Model.ObjectState(stateData);
|
||
|
||
this._clusterOptions = {
|
||
zoomToBoundsOnClick: true,
|
||
//disableClusteringAtZoom: 10,
|
||
maxClusterRadius: 80,
|
||
singleMarkerMode: false,
|
||
skipDuplicateAddTesting: true,
|
||
animateAddingMarkers: false
|
||
};
|
||
|
||
// Listen to changes in the fields
|
||
this.listenTo(this.model.fields, 'change', function() {
|
||
self._setupGeometryField();
|
||
self.render();
|
||
});
|
||
|
||
// Listen to changes in the records
|
||
this.listenTo(this.model.records, 'add', function(doc){self.redraw('add',doc);});
|
||
this.listenTo(this.model.records, 'change', function(doc){
|
||
self.redraw('remove',doc);
|
||
self.redraw('add',doc);
|
||
});
|
||
this.listenTo(this.model.records, 'remove', function(doc){self.redraw('remove',doc);});
|
||
this.listenTo(this.model.records, 'reset', function(){self.redraw('reset');});
|
||
|
||
this.menu = new my.MapMenu({
|
||
model: this.model,
|
||
state: this.state.toJSON()
|
||
});
|
||
this.listenTo(this.menu.state, 'change', function() {
|
||
self.state.set(self.menu.state.toJSON());
|
||
self.redraw();
|
||
});
|
||
this.listenTo(this.state, 'change', function() {
|
||
self.redraw();
|
||
});
|
||
this.elSidebar = this.menu.$el;
|
||
},
|
||
|
||
// ## Customization Functions
|
||
//
|
||
// The following methods are designed for overriding in order to customize
|
||
// behaviour
|
||
|
||
// ### infobox
|
||
//
|
||
// Function to create infoboxes used in popups. The default behaviour is very simple and just lists all attributes.
|
||
//
|
||
// Users should override this function to customize behaviour i.e.
|
||
//
|
||
// view = new View({...});
|
||
// view.infobox = function(record) {
|
||
// ...
|
||
// }
|
||
infobox: function(record) {
|
||
var html = '';
|
||
for (var key in record.attributes){
|
||
if (!(this.state.get('geomField') && key == this.state.get('geomField'))){
|
||
html += '<div><strong>' + key + '</strong>: '+ record.attributes[key] + '</div>';
|
||
}
|
||
}
|
||
return html;
|
||
},
|
||
|
||
// Options to use for the [Leaflet GeoJSON layer](http://leaflet.cloudmade.com/reference.html#geojson)
|
||
// See also <http://leaflet.cloudmade.com/examples/geojson.html>
|
||
//
|
||
// e.g.
|
||
//
|
||
// pointToLayer: function(feature, latLng)
|
||
// onEachFeature: function(feature, layer)
|
||
//
|
||
// See defaults for examples
|
||
geoJsonLayerOptions: {
|
||
// pointToLayer function to use when creating points
|
||
//
|
||
// Default behaviour shown here is to create a marker using the
|
||
// popupContent set on the feature properties (created via infobox function
|
||
// during feature generation)
|
||
//
|
||
// NB: inside pointToLayer `this` will be set to point to this map view
|
||
// instance (which allows e.g. this.markers to work in this default case)
|
||
pointToLayer: function (feature, latlng) {
|
||
var marker = new L.Marker(latlng);
|
||
marker.bindPopup(feature.properties.popupContent);
|
||
// this is for cluster case
|
||
this.markers.addLayer(marker);
|
||
return marker;
|
||
},
|
||
// onEachFeature default which adds popup in
|
||
onEachFeature: function(feature, layer) {
|
||
if (feature.properties && feature.properties.popupContent) {
|
||
layer.bindPopup(feature.properties.popupContent);
|
||
}
|
||
}
|
||
},
|
||
|
||
// END: Customization section
|
||
// ----
|
||
|
||
// ### Public: Adds the necessary elements to the page.
|
||
//
|
||
// Also sets up the editor fields and the map if necessary.
|
||
render: function() {
|
||
var self = this;
|
||
var htmls = Mustache.render(this.template, this.model.toTemplateJSON());
|
||
this.$el.html(htmls);
|
||
this.$map = this.$el.find('.panel.map');
|
||
this.redraw();
|
||
return this;
|
||
},
|
||
|
||
// ### Public: Redraws the features on the map according to the action provided
|
||
//
|
||
// Actions can be:
|
||
//
|
||
// * reset: Clear all features
|
||
// * add: Add one or n features (records)
|
||
// * remove: Remove one or n features (records)
|
||
// * refresh: Clear existing features and add all current records
|
||
redraw: function(action, doc){
|
||
var self = this;
|
||
action = action || 'refresh';
|
||
// try to set things up if not already
|
||
if (!self._geomReady()){
|
||
self._setupGeometryField();
|
||
}
|
||
if (!self.mapReady){
|
||
self._setupMap();
|
||
}
|
||
|
||
if (this._geomReady() && this.mapReady){
|
||
// removing ad re-adding the layer enables faster bulk loading
|
||
this.map.removeLayer(this.features);
|
||
this.map.removeLayer(this.markers);
|
||
|
||
var countBefore = 0;
|
||
this.features.eachLayer(function(){countBefore++;});
|
||
|
||
if (action == 'refresh' || action == 'reset') {
|
||
this.features.clearLayers();
|
||
// recreate cluster group because of issues with clearLayer
|
||
this.map.removeLayer(this.markers);
|
||
this.markers = new L.MarkerClusterGroup(this._clusterOptions);
|
||
this._add(this.model.records.models);
|
||
} else if (action == 'add' && doc){
|
||
this._add(doc);
|
||
} else if (action == 'remove' && doc){
|
||
this._remove(doc);
|
||
}
|
||
|
||
// enable clustering if there is a large number of markers
|
||
var countAfter = 0;
|
||
this.features.eachLayer(function(){countAfter++;});
|
||
var sizeIncreased = countAfter - countBefore > 0;
|
||
if (!this.state.get('cluster') && countAfter > 64 && sizeIncreased) {
|
||
this.state.set({cluster: true});
|
||
return;
|
||
}
|
||
|
||
// this must come before zooming!
|
||
// if not: errors when using e.g. circle markers like
|
||
// "Cannot call method 'project' of undefined"
|
||
if (this.state.get('cluster')) {
|
||
this.map.addLayer(this.markers);
|
||
} else {
|
||
this.map.addLayer(this.features);
|
||
}
|
||
|
||
if (this.state.get('autoZoom')){
|
||
if (this.visible){
|
||
this._zoomToFeatures();
|
||
} else {
|
||
this._zoomPending = true;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
show: function() {
|
||
// If the div was hidden, Leaflet needs to recalculate some sizes
|
||
// to display properly
|
||
if (this.map){
|
||
this.map.invalidateSize();
|
||
if (this._zoomPending && this.state.get('autoZoom')) {
|
||
this._zoomToFeatures();
|
||
this._zoomPending = false;
|
||
}
|
||
}
|
||
this.visible = true;
|
||
},
|
||
|
||
hide: function() {
|
||
this.visible = false;
|
||
},
|
||
|
||
_geomReady: function() {
|
||
return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
|
||
},
|
||
|
||
// Private: Add one or n features to the map
|
||
//
|
||
// For each record passed, a GeoJSON geometry will be extracted and added
|
||
// to the features layer. If an exception is thrown, the process will be
|
||
// stopped and an error notification shown.
|
||
//
|
||
// Each feature will have a popup associated with all the record fields.
|
||
//
|
||
_add: function(docs){
|
||
var self = this;
|
||
|
||
if (!(docs instanceof Array)) docs = [docs];
|
||
|
||
var count = 0;
|
||
var wrongSoFar = 0;
|
||
_.every(docs, function(doc){
|
||
count += 1;
|
||
var feature = self._getGeometryFromRecord(doc);
|
||
if (typeof feature === 'undefined' || feature === null){
|
||
// Empty field
|
||
return true;
|
||
} else if (feature instanceof Object){
|
||
feature.properties = {
|
||
popupContent: self.infobox(doc),
|
||
// Add a reference to the model id, which will allow us to
|
||
// link this Leaflet layer to a Recline doc
|
||
cid: doc.cid
|
||
};
|
||
|
||
try {
|
||
self.features.addData(feature);
|
||
} catch (except) {
|
||
wrongSoFar += 1;
|
||
var msg = 'Wrong geometry value';
|
||
if (except.message) msg += ' (' + except.message + ')';
|
||
if (wrongSoFar <= 10) {
|
||
self.trigger('recline:flash', {message: msg, category:'error'});
|
||
}
|
||
}
|
||
} else {
|
||
wrongSoFar += 1;
|
||
if (wrongSoFar <= 10) {
|
||
self.trigger('recline:flash', {message: 'Wrong geometry value', category:'error'});
|
||
}
|
||
}
|
||
return true;
|
||
});
|
||
},
|
||
|
||
// Private: Remove one or n features from the map
|
||
//
|
||
_remove: function(docs){
|
||
|
||
var self = this;
|
||
|
||
if (!(docs instanceof Array)) docs = [docs];
|
||
|
||
_.each(docs,function(doc){
|
||
for (var key in self.features._layers){
|
||
if (self.features._layers[key].feature.properties.cid == doc.cid){
|
||
self.features.removeLayer(self.features._layers[key]);
|
||
}
|
||
}
|
||
});
|
||
|
||
},
|
||
|
||
// Private: Return a GeoJSON geomtry extracted from the record fields
|
||
//
|
||
_getGeometryFromRecord: function(doc){
|
||
if (this.state.get('geomField')){
|
||
var value = doc.get(this.state.get('geomField'));
|
||
if (typeof(value) === 'string'){
|
||
// We *may* have a GeoJSON string representation
|
||
try {
|
||
value = $.parseJSON(value);
|
||
} catch(e) {}
|
||
}
|
||
|
||
if (typeof(value) === 'string') {
|
||
value = value.replace('(', '').replace(')', '');
|
||
var parts = value.split(',');
|
||
var lat = parseFloat(parts[0]);
|
||
var lon = parseFloat(parts[1]);
|
||
if (!isNaN(lon) && !isNaN(parseFloat(lat))) {
|
||
return {
|
||
"type": "Point",
|
||
"coordinates": [lon, lat]
|
||
};
|
||
} else {
|
||
return null;
|
||
}
|
||
} else if (value && _.isArray(value)) {
|
||
// [ lon, lat ]
|
||
return {
|
||
"type": "Point",
|
||
"coordinates": [value[0], value[1]]
|
||
};
|
||
} else if (value && value.lat) {
|
||
// of form { lat: ..., lon: ...}
|
||
return {
|
||
"type": "Point",
|
||
"coordinates": [value.lon || value.lng, value.lat]
|
||
};
|
||
}
|
||
// We o/w assume that contents of the field are a valid GeoJSON object
|
||
return value;
|
||
} else if (this.state.get('lonField') && this.state.get('latField')){
|
||
// We'll create a GeoJSON like point object from the two lat/lon fields
|
||
var lon = doc.get(this.state.get('lonField'));
|
||
var lat = doc.get(this.state.get('latField'));
|
||
if (!isNaN(parseFloat(lon)) && !isNaN(parseFloat(lat))) {
|
||
return {
|
||
type: 'Point',
|
||
coordinates: [lon,lat]
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// Private: Check if there is a field with GeoJSON geometries or alternatively,
|
||
// two fields with lat/lon values.
|
||
//
|
||
// If not found, the user can define them via the UI form.
|
||
_setupGeometryField: function(){
|
||
// should not overwrite if we have already set this (e.g. explicitly via state)
|
||
if (!this._geomReady()) {
|
||
this.state.set({
|
||
geomField: this._checkField(this.geometryFieldNames),
|
||
latField: this._checkField(this.latitudeFieldNames),
|
||
lonField: this._checkField(this.longitudeFieldNames)
|
||
});
|
||
this.menu.state.set(this.state.toJSON());
|
||
}
|
||
},
|
||
|
||
// Private: Check if a field in the current model exists in the provided
|
||
// list of names.
|
||
//
|
||
//
|
||
_checkField: function(fieldNames){
|
||
var field;
|
||
var modelFieldNames = this.model.fields.pluck('id');
|
||
for (var i = 0; i < fieldNames.length; i++){
|
||
for (var j = 0; j < modelFieldNames.length; j++){
|
||
if (modelFieldNames[j].toLowerCase() == fieldNames[i].toLowerCase())
|
||
return modelFieldNames[j];
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// Private: Zoom to map to current features extent if any, or to the full
|
||
// extent if none.
|
||
//
|
||
_zoomToFeatures: function(){
|
||
var bounds = this.features.getBounds();
|
||
if (bounds && bounds.getNorthEast() && bounds.getSouthWest()){
|
||
this.map.fitBounds(bounds);
|
||
} else {
|
||
this.map.setView([0, 0], 2);
|
||
}
|
||
},
|
||
|
||
// Private: Sets up the Leaflet map control and the features layer.
|
||
//
|
||
// The map uses a base layer from [MapQuest](http://www.mapquest.com) based
|
||
// on [OpenStreetMap](http://openstreetmap.org).
|
||
//
|
||
_setupMap: function(){
|
||
var self = this;
|
||
this.map = new L.Map(this.$map.get(0));
|
||
|
||
var mapUrl = "http://otile{s}.mqcdn.com/tiles/1.0.0/osm/{z}/{x}/{y}.png";
|
||
var osmAttribution = 'Map data © 2011 OpenStreetMap contributors, Tiles Courtesy of <a href="http://www.mapquest.com/" target="_blank">MapQuest</a> <img src="http://developer.mapquest.com/content/osm/mq_logo.png">';
|
||
var bg = new L.TileLayer(mapUrl, {maxZoom: 18, attribution: osmAttribution ,subdomains: '1234'});
|
||
this.map.addLayer(bg);
|
||
|
||
this.markers = new L.MarkerClusterGroup(this._clusterOptions);
|
||
|
||
// rebind this (as needed in e.g. default case above)
|
||
this.geoJsonLayerOptions.pointToLayer = _.bind(
|
||
this.geoJsonLayerOptions.pointToLayer,
|
||
this);
|
||
this.features = new L.GeoJSON(null, this.geoJsonLayerOptions);
|
||
|
||
this.map.setView([0, 0], 2);
|
||
|
||
this.mapReady = true;
|
||
},
|
||
|
||
// Private: Helper function to select an option from a select list
|
||
//
|
||
_selectOption: function(id,value){
|
||
var options = $('.' + id + ' > select > option');
|
||
if (options){
|
||
options.each(function(opt){
|
||
if (this.value == value) {
|
||
$(this).attr('selected','selected');
|
||
return false;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
my.MapMenu = Backbone.View.extend({
|
||
className: 'editor',
|
||
|
||
template: ' \
|
||
<form class="form-stacked"> \
|
||
<div class="clearfix"> \
|
||
<div class="editor-field-type"> \
|
||
<label class="radio"> \
|
||
<input type="radio" id="editor-field-type-latlon" name="editor-field-type" value="latlon" checked="checked"/> \
|
||
Latitude / Longitude fields</label> \
|
||
<label class="radio"> \
|
||
<input type="radio" id="editor-field-type-geom" name="editor-field-type" value="geom" /> \
|
||
GeoJSON field</label> \
|
||
</div> \
|
||
<div class="editor-field-type-latlon"> \
|
||
<label>Latitude field</label> \
|
||
<div class="input editor-lat-field"> \
|
||
<select> \
|
||
<option value=""></option> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
</div> \
|
||
<label>Longitude field</label> \
|
||
<div class="input editor-lon-field"> \
|
||
<select> \
|
||
<option value=""></option> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
</div> \
|
||
</div> \
|
||
<div class="editor-field-type-geom" style="display:none"> \
|
||
<label>Geometry field (GeoJSON)</label> \
|
||
<div class="input editor-geom-field"> \
|
||
<select> \
|
||
<option value=""></option> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
</div> \
|
||
</div> \
|
||
</div> \
|
||
<div class="editor-buttons"> \
|
||
<button class="btn editor-update-map">Update</button> \
|
||
</div> \
|
||
<div class="editor-options" > \
|
||
<label class="checkbox"> \
|
||
<input type="checkbox" id="editor-auto-zoom" value="autozoom" checked="checked" /> \
|
||
Auto zoom to features</label> \
|
||
<label class="checkbox"> \
|
||
<input type="checkbox" id="editor-cluster" value="cluster"/> \
|
||
Cluster markers</label> \
|
||
</div> \
|
||
<input type="hidden" class="editor-id" value="map-1" /> \
|
||
</form> \
|
||
',
|
||
|
||
// Define here events for UI elements
|
||
events: {
|
||
'click .editor-update-map': 'onEditorSubmit',
|
||
'change .editor-field-type': 'onFieldTypeChange',
|
||
'click #editor-auto-zoom': 'onAutoZoomChange',
|
||
'click #editor-cluster': 'onClusteringChange'
|
||
},
|
||
|
||
initialize: function(options) {
|
||
var self = this;
|
||
_.bindAll(this, 'render');
|
||
this.listenTo(this.model.fields, 'change', this.render);
|
||
this.state = new recline.Model.ObjectState(options.state);
|
||
this.listenTo(this.state, 'change', this.render);
|
||
this.render();
|
||
},
|
||
|
||
// ### Public: Adds the necessary elements to the page.
|
||
//
|
||
// Also sets up the editor fields and the map if necessary.
|
||
render: function() {
|
||
var self = this;
|
||
var htmls = Mustache.render(this.template, this.model.toTemplateJSON());
|
||
this.$el.html(htmls);
|
||
|
||
if (this._geomReady() && this.model.fields.length){
|
||
if (this.state.get('geomField')){
|
||
this._selectOption('editor-geom-field',this.state.get('geomField'));
|
||
this.$el.find('#editor-field-type-geom').attr('checked','checked').change();
|
||
} else{
|
||
this._selectOption('editor-lon-field',this.state.get('lonField'));
|
||
this._selectOption('editor-lat-field',this.state.get('latField'));
|
||
this.$el.find('#editor-field-type-latlon').attr('checked','checked').change();
|
||
}
|
||
}
|
||
if (this.state.get('autoZoom')) {
|
||
this.$el.find('#editor-auto-zoom').attr('checked', 'checked');
|
||
} else {
|
||
this.$el.find('#editor-auto-zoom').removeAttr('checked');
|
||
}
|
||
if (this.state.get('cluster')) {
|
||
this.$el.find('#editor-cluster').attr('checked', 'checked');
|
||
} else {
|
||
this.$el.find('#editor-cluster').removeAttr('checked');
|
||
}
|
||
return this;
|
||
},
|
||
|
||
_geomReady: function() {
|
||
return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
|
||
},
|
||
|
||
// ## UI Event handlers
|
||
//
|
||
|
||
// Public: Update map with user options
|
||
//
|
||
// Right now the only configurable option is what field(s) contains the
|
||
// location information.
|
||
//
|
||
onEditorSubmit: function(e){
|
||
e.preventDefault();
|
||
if (this.$el.find('#editor-field-type-geom').attr('checked')){
|
||
this.state.set({
|
||
geomField: this.$el.find('.editor-geom-field > select > option:selected').val(),
|
||
lonField: null,
|
||
latField: null
|
||
});
|
||
} else {
|
||
this.state.set({
|
||
geomField: null,
|
||
lonField: this.$el.find('.editor-lon-field > select > option:selected').val(),
|
||
latField: this.$el.find('.editor-lat-field > select > option:selected').val()
|
||
});
|
||
}
|
||
return false;
|
||
},
|
||
|
||
// Public: Shows the relevant select lists depending on the location field
|
||
// type selected.
|
||
//
|
||
onFieldTypeChange: function(e){
|
||
if (e.target.value == 'geom'){
|
||
this.$el.find('.editor-field-type-geom').show();
|
||
this.$el.find('.editor-field-type-latlon').hide();
|
||
} else {
|
||
this.$el.find('.editor-field-type-geom').hide();
|
||
this.$el.find('.editor-field-type-latlon').show();
|
||
}
|
||
},
|
||
|
||
onAutoZoomChange: function(e){
|
||
this.state.set({autoZoom: !this.state.get('autoZoom')});
|
||
},
|
||
|
||
onClusteringChange: function(e){
|
||
this.state.set({cluster: !this.state.get('cluster')});
|
||
},
|
||
|
||
// 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;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
})(jQuery, recline.View);
|
||
|
||
/*jshint multistr:true */
|
||
|
||
// Standard JS module setup
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
// ## MultiView
|
||
//
|
||
// Manage multiple views together along with query editor etc. Usage:
|
||
//
|
||
// <pre>
|
||
// var myExplorer = new model.recline.MultiView({
|
||
// model: {{recline.Model.Dataset instance}}
|
||
// el: {{an existing dom element}}
|
||
// views: {{dataset views}}
|
||
// state: {{state configuration -- see below}}
|
||
// });
|
||
// </pre>
|
||
//
|
||
// ### Parameters
|
||
//
|
||
// **model**: (required) recline.model.Dataset instance.
|
||
//
|
||
// **el**: (required) DOM element to bind to. NB: the element already
|
||
// being in the DOM is important for rendering of some subviews (e.g.
|
||
// Graph).
|
||
//
|
||
// **views**: (optional) the dataset views (Grid, Graph etc) for
|
||
// MultiView to show. This is an array of view hashes. If not provided
|
||
// initialize with (recline.View.)Grid, Graph, and Map views (with obvious id
|
||
// and labels!).
|
||
//
|
||
// <pre>
|
||
// var views = [
|
||
// {
|
||
// id: 'grid', // used for routing
|
||
// label: 'Grid', // used for view switcher
|
||
// view: new recline.View.Grid({
|
||
// model: dataset
|
||
// })
|
||
// },
|
||
// {
|
||
// id: 'graph',
|
||
// label: 'Graph',
|
||
// view: new recline.View.Graph({
|
||
// model: dataset
|
||
// })
|
||
// }
|
||
// ];
|
||
// </pre>
|
||
//
|
||
// **sidebarViews**: (optional) the sidebar views (Filters, Fields) for
|
||
// MultiView to show. This is an array of view hashes. If not provided
|
||
// initialize with (recline.View.)FilterEditor and Fields views (with obvious
|
||
// id and labels!).
|
||
//
|
||
// <pre>
|
||
// var sidebarViews = [
|
||
// {
|
||
// id: 'filterEditor', // used for routing
|
||
// label: 'Filters', // used for view switcher
|
||
// view: new recline.View.FielterEditor({
|
||
// model: dataset
|
||
// })
|
||
// },
|
||
// {
|
||
// id: 'fieldsView',
|
||
// label: 'Fields',
|
||
// view: new recline.View.Fields({
|
||
// model: dataset
|
||
// })
|
||
// }
|
||
// ];
|
||
// </pre>
|
||
//
|
||
// **state**: standard state config for this view. This state is slightly
|
||
// special as it includes config of many of the subviews.
|
||
//
|
||
// <pre>
|
||
// state = {
|
||
// query: {dataset query state - see dataset.queryState object}
|
||
// view-{id1}: {view-state for this view}
|
||
// view-{id2}: {view-state for }
|
||
// ...
|
||
// // Explorer
|
||
// currentView: id of current view (defaults to first view if not specified)
|
||
// readOnly: (default: false) run in read-only mode
|
||
// }
|
||
// </pre>
|
||
//
|
||
// Note that at present we do *not* serialize information about the actual set
|
||
// of views in use -- e.g. those specified by the views argument -- but instead
|
||
// expect either that the default views are fine or that the client to have
|
||
// initialized the MultiView with the relevant views themselves.
|
||
my.MultiView = Backbone.View.extend({
|
||
template: ' \
|
||
<div class="recline-data-explorer"> \
|
||
<div class="alert-messages"></div> \
|
||
\
|
||
<div class="header clearfix"> \
|
||
<div class="navigation"> \
|
||
<div class="btn-group" data-toggle="buttons-radio"> \
|
||
{{#views}} \
|
||
<a href="#{{id}}" data-view="{{id}}" class="btn">{{label}}</a> \
|
||
{{/views}} \
|
||
</div> \
|
||
</div> \
|
||
<div class="recline-results-info"> \
|
||
<span class="doc-count">{{recordCount}}</span> records\
|
||
</div> \
|
||
<div class="menu-right"> \
|
||
<div class="btn-group" data-toggle="buttons-checkbox"> \
|
||
{{#sidebarViews}} \
|
||
<a href="#" data-action="{{id}}" class="btn">{{label}}</a> \
|
||
{{/sidebarViews}} \
|
||
</div> \
|
||
</div> \
|
||
<div class="query-editor-here" style="display:inline;"></div> \
|
||
</div> \
|
||
<div class="data-view-sidebar"></div> \
|
||
<div class="data-view-container"></div> \
|
||
</div> \
|
||
',
|
||
events: {
|
||
'click .menu-right a': '_onMenuClick',
|
||
'click .navigation a': '_onSwitchView'
|
||
},
|
||
|
||
initialize: function(options) {
|
||
var self = this;
|
||
this._setupState(options.state);
|
||
|
||
// Hash of 'page' views (i.e. those for whole page) keyed by page name
|
||
if (options.views) {
|
||
this.pageViews = options.views;
|
||
} else {
|
||
this.pageViews = [{
|
||
id: 'grid',
|
||
label: 'Grid',
|
||
view: new my.SlickGrid({
|
||
model: this.model,
|
||
state: this.state.get('view-grid')
|
||
})
|
||
}, {
|
||
id: 'graph',
|
||
label: 'Graph',
|
||
view: new my.Graph({
|
||
model: this.model,
|
||
state: this.state.get('view-graph')
|
||
})
|
||
}, {
|
||
id: 'map',
|
||
label: 'Map',
|
||
view: new my.Map({
|
||
model: this.model,
|
||
state: this.state.get('view-map')
|
||
})
|
||
}, {
|
||
id: 'timeline',
|
||
label: 'Timeline',
|
||
view: new my.Timeline({
|
||
model: this.model,
|
||
state: this.state.get('view-timeline')
|
||
})
|
||
}];
|
||
}
|
||
// Hashes of sidebar elements
|
||
if(options.sidebarViews) {
|
||
this.sidebarViews = options.sidebarViews;
|
||
} else {
|
||
this.sidebarViews = [{
|
||
id: 'filterEditor',
|
||
label: 'Filters',
|
||
view: new my.FilterEditor({
|
||
model: this.model
|
||
})
|
||
}, {
|
||
id: 'fieldsView',
|
||
label: 'Fields',
|
||
view: new my.Fields({
|
||
model: this.model
|
||
})
|
||
}];
|
||
}
|
||
// these must be called after pageViews are created
|
||
this.render();
|
||
this._bindStateChanges();
|
||
this._bindFlashNotifications();
|
||
// now do updates based on state (need to come after render)
|
||
if (this.state.get('readOnly')) {
|
||
this.setReadOnly();
|
||
}
|
||
if (this.state.get('currentView')) {
|
||
this.updateNav(this.state.get('currentView'));
|
||
} else {
|
||
this.updateNav(this.pageViews[0].id);
|
||
}
|
||
this._showHideSidebar();
|
||
|
||
this.listenTo(this.model, 'query:start', function() {
|
||
self.notify({loader: true, persist: true});
|
||
});
|
||
this.listenTo(this.model, 'query:done', function() {
|
||
self.clearNotifications();
|
||
self.$el.find('.doc-count').text(self.model.recordCount || 'Unknown');
|
||
});
|
||
this.listenTo(this.model, 'query:fail', function(error) {
|
||
self.clearNotifications();
|
||
var msg = '';
|
||
if (typeof(error) == 'string') {
|
||
msg = error;
|
||
} else if (typeof(error) == 'object') {
|
||
if (error.title) {
|
||
msg = error.title + ': ';
|
||
}
|
||
if (error.message) {
|
||
msg += error.message;
|
||
}
|
||
} else {
|
||
msg = 'There was an error querying the backend';
|
||
}
|
||
self.notify({message: msg, category: 'error', persist: true});
|
||
});
|
||
|
||
// retrieve basic data like fields etc
|
||
// note this.model and dataset returned are the same
|
||
// TODO: set query state ...?
|
||
this.model.queryState.set(self.state.get('query'), {silent: true});
|
||
this.model.fetch()
|
||
.fail(function(error) {
|
||
self.notify({message: error.message, category: 'error', persist: true});
|
||
});
|
||
},
|
||
|
||
setReadOnly: function() {
|
||
this.$el.addClass('recline-read-only');
|
||
},
|
||
|
||
render: function() {
|
||
var tmplData = this.model.toTemplateJSON();
|
||
tmplData.views = this.pageViews;
|
||
tmplData.sidebarViews = this.sidebarViews;
|
||
var template = Mustache.render(this.template, tmplData);
|
||
this.$el.html(template);
|
||
|
||
// now create and append other views
|
||
var $dataViewContainer = this.$el.find('.data-view-container');
|
||
var $dataSidebar = this.$el.find('.data-view-sidebar');
|
||
|
||
// the main views
|
||
_.each(this.pageViews, function(view, pageName) {
|
||
view.view.render();
|
||
$dataViewContainer.append(view.view.el);
|
||
if (view.view.elSidebar) {
|
||
$dataSidebar.append(view.view.elSidebar);
|
||
}
|
||
});
|
||
|
||
_.each(this.sidebarViews, function(view) {
|
||
this['$'+view.id] = view.view.$el;
|
||
$dataSidebar.append(view.view.el);
|
||
}, this);
|
||
|
||
this.pager = new recline.View.Pager({
|
||
model: this.model.queryState
|
||
});
|
||
this.$el.find('.recline-results-info').after(this.pager.el);
|
||
|
||
this.queryEditor = new recline.View.QueryEditor({
|
||
model: this.model.queryState
|
||
});
|
||
this.$el.find('.query-editor-here').append(this.queryEditor.el);
|
||
|
||
},
|
||
|
||
remove: function () {
|
||
_.each(this.pageViews, function (view) {
|
||
view.view.remove();
|
||
});
|
||
_.each(this.sidebarViews, function (view) {
|
||
view.view.remove();
|
||
});
|
||
this.pager.remove();
|
||
this.queryEditor.remove();
|
||
Backbone.View.prototype.remove.apply(this, arguments);
|
||
},
|
||
|
||
// hide the sidebar if empty
|
||
_showHideSidebar: function() {
|
||
var $dataSidebar = this.$el.find('.data-view-sidebar');
|
||
var visibleChildren = $dataSidebar.children().filter(function() {
|
||
return $(this).css("display") != "none";
|
||
}).length;
|
||
|
||
if (visibleChildren > 0) {
|
||
$dataSidebar.show();
|
||
} else {
|
||
$dataSidebar.hide();
|
||
}
|
||
},
|
||
|
||
updateNav: function(pageName) {
|
||
this.$el.find('.navigation a').removeClass('active');
|
||
var $el = this.$el.find('.navigation a[data-view="' + pageName + '"]');
|
||
$el.addClass('active');
|
||
|
||
// add/remove sidebars and hide inactive views
|
||
_.each(this.pageViews, function(view, idx) {
|
||
if (view.id === pageName) {
|
||
view.view.$el.show();
|
||
if (view.view.elSidebar) {
|
||
view.view.elSidebar.show();
|
||
}
|
||
} else {
|
||
view.view.$el.hide();
|
||
if (view.view.elSidebar) {
|
||
view.view.elSidebar.hide();
|
||
}
|
||
if (view.view.hide) {
|
||
view.view.hide();
|
||
}
|
||
}
|
||
});
|
||
|
||
this._showHideSidebar();
|
||
|
||
// call view.view.show after sidebar visibility has been determined so
|
||
// that views can correctly calculate their maximum width
|
||
_.each(this.pageViews, function(view, idx) {
|
||
if (view.id === pageName) {
|
||
if (view.view.show) {
|
||
view.view.show();
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
_onMenuClick: function(e) {
|
||
e.preventDefault();
|
||
var action = $(e.target).attr('data-action');
|
||
this['$'+action].toggle();
|
||
this._showHideSidebar();
|
||
},
|
||
|
||
_onSwitchView: function(e) {
|
||
e.preventDefault();
|
||
var viewName = $(e.target).attr('data-view');
|
||
this.updateNav(viewName);
|
||
this.state.set({currentView: viewName});
|
||
},
|
||
|
||
// create a state object for this view and do the job of
|
||
//
|
||
// a) initializing it from both data passed in and other sources (e.g. hash url)
|
||
//
|
||
// b) ensure the state object is updated in responese to changes in subviews, query etc.
|
||
_setupState: function(initialState) {
|
||
var self = this;
|
||
// get data from the query string / hash url plus some defaults
|
||
var qs = my.parseHashQueryString();
|
||
var query = qs.reclineQuery;
|
||
query = query ? JSON.parse(query) : self.model.queryState.toJSON();
|
||
// backwards compatability (now named view-graph but was named graph)
|
||
var graphState = qs['view-graph'] || qs.graph;
|
||
graphState = graphState ? JSON.parse(graphState) : {};
|
||
|
||
// now get default data + hash url plus initial state and initial our state object with it
|
||
var stateData = _.extend({
|
||
query: query,
|
||
'view-graph': graphState,
|
||
backend: this.model.backend.__type__,
|
||
url: this.model.get('url'),
|
||
dataset: this.model.toJSON(),
|
||
currentView: null,
|
||
readOnly: false
|
||
},
|
||
initialState);
|
||
this.state = new recline.Model.ObjectState(stateData);
|
||
},
|
||
|
||
_bindStateChanges: function() {
|
||
var self = this;
|
||
// finally ensure we update our state object when state of sub-object changes so that state is always up to date
|
||
this.listenTo(this.model.queryState, 'change', function() {
|
||
self.state.set({query: self.model.queryState.toJSON()});
|
||
});
|
||
_.each(this.pageViews, function(pageView) {
|
||
if (pageView.view.state && pageView.view.state.bind) {
|
||
var update = {};
|
||
update['view-' + pageView.id] = pageView.view.state.toJSON();
|
||
self.state.set(update);
|
||
self.listenTo(pageView.view.state, 'change', function() {
|
||
var update = {};
|
||
update['view-' + pageView.id] = pageView.view.state.toJSON();
|
||
// had problems where change not being triggered for e.g. grid view so let's do it explicitly
|
||
self.state.set(update, {silent: true});
|
||
self.state.trigger('change');
|
||
});
|
||
}
|
||
});
|
||
},
|
||
|
||
_bindFlashNotifications: function() {
|
||
var self = this;
|
||
_.each(this.pageViews, function(pageView) {
|
||
self.listenTo(pageView.view, 'recline:flash', function(flash) {
|
||
self.notify(flash);
|
||
});
|
||
});
|
||
},
|
||
|
||
// ### notify
|
||
//
|
||
// Create a notification (a div.alert in div.alert-messsages) using provided
|
||
// flash object. Flash attributes (all are optional):
|
||
//
|
||
// * message: message to show.
|
||
// * category: warning (default), success, error
|
||
// * persist: if true alert is persistent, o/w hidden after 3s (default = false)
|
||
// * loader: if true show loading spinner
|
||
notify: function(flash) {
|
||
var tmplData = _.extend({
|
||
message: 'Loading',
|
||
category: 'warning',
|
||
loader: false
|
||
},
|
||
flash
|
||
);
|
||
var _template;
|
||
if (tmplData.loader) {
|
||
_template = ' \
|
||
<div class="alert alert-info alert-loader"> \
|
||
{{message}} \
|
||
<span class="notification-loader"> </span> \
|
||
</div>';
|
||
} else {
|
||
_template = ' \
|
||
<div class="alert alert-{{category}} fade in" data-alert="alert"><a class="close" data-dismiss="alert" href="#">×</a> \
|
||
{{message}} \
|
||
</div>';
|
||
}
|
||
var _templated = $(Mustache.render(_template, tmplData));
|
||
_templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages'));
|
||
if (!flash.persist) {
|
||
setTimeout(function() {
|
||
$(_templated).fadeOut(1000, function() {
|
||
$(this).remove();
|
||
});
|
||
}, 1000);
|
||
}
|
||
},
|
||
|
||
// ### clearNotifications
|
||
//
|
||
// Clear all existing notifications
|
||
clearNotifications: function() {
|
||
var $notifications = $('.recline-data-explorer .alert-messages .alert');
|
||
$notifications.fadeOut(1500, function() {
|
||
$(this).remove();
|
||
});
|
||
}
|
||
});
|
||
|
||
// ### MultiView.restore
|
||
//
|
||
// Restore a MultiView instance from a serialized state including the associated dataset
|
||
//
|
||
// This inverts the state serialization process in Multiview
|
||
my.MultiView.restore = function(state) {
|
||
// hack-y - restoring a memory dataset does not mean much ... (but useful for testing!)
|
||
var datasetInfo;
|
||
if (state.backend === 'memory') {
|
||
datasetInfo = {
|
||
backend: 'memory',
|
||
records: [{stub: 'this is a stub dataset because we do not restore memory datasets'}]
|
||
};
|
||
} else {
|
||
datasetInfo = _.extend({
|
||
url: state.url,
|
||
backend: state.backend
|
||
},
|
||
state.dataset
|
||
);
|
||
}
|
||
var dataset = new recline.Model.Dataset(datasetInfo);
|
||
var explorer = new my.MultiView({
|
||
model: dataset,
|
||
state: state
|
||
});
|
||
return explorer;
|
||
};
|
||
|
||
// ## Miscellaneous Utilities
|
||
var urlPathRegex = /^([^?]+)(\?.*)?/;
|
||
|
||
// Parse the Hash section of a URL into path and query string
|
||
my.parseHashUrl = function(hashUrl) {
|
||
var parsed = urlPathRegex.exec(hashUrl);
|
||
if (parsed === null) {
|
||
return {};
|
||
} else {
|
||
return {
|
||
path: parsed[1],
|
||
query: parsed[2] || ''
|
||
};
|
||
}
|
||
};
|
||
|
||
// Parse a URL query string (?xyz=abc...) into a dictionary.
|
||
my.parseQueryString = function(q) {
|
||
if (!q) {
|
||
return {};
|
||
}
|
||
var urlParams = {},
|
||
e, d = function (s) {
|
||
return unescape(s.replace(/\+/g, " "));
|
||
},
|
||
r = /([^&=]+)=?([^&]*)/g;
|
||
|
||
if (q && q.length && q[0] === '?') {
|
||
q = q.slice(1);
|
||
}
|
||
while (e = r.exec(q)) {
|
||
// TODO: have values be array as query string allow repetition of keys
|
||
urlParams[d(e[1])] = d(e[2]);
|
||
}
|
||
return urlParams;
|
||
};
|
||
|
||
// Parse the query string out of the URL hash
|
||
my.parseHashQueryString = function() {
|
||
var q = my.parseHashUrl(window.location.hash).query;
|
||
return my.parseQueryString(q);
|
||
};
|
||
|
||
// Compse a Query String
|
||
my.composeQueryString = function(queryParams) {
|
||
var queryString = '?';
|
||
var items = [];
|
||
$.each(queryParams, function(key, value) {
|
||
if (typeof(value) === 'object') {
|
||
value = JSON.stringify(value);
|
||
}
|
||
items.push(key + '=' + encodeURIComponent(value));
|
||
});
|
||
queryString += items.join('&');
|
||
return queryString;
|
||
};
|
||
|
||
my.getNewHashForQueryString = function(queryParams) {
|
||
var queryPart = my.composeQueryString(queryParams);
|
||
if (window.location.hash) {
|
||
// slice(1) to remove # at start
|
||
return window.location.hash.split('?')[0].slice(1) + queryPart;
|
||
} else {
|
||
return queryPart;
|
||
}
|
||
};
|
||
|
||
my.setHashQueryString = function(queryParams) {
|
||
window.location.hash = my.getNewHashForQueryString(queryParams);
|
||
};
|
||
|
||
})(jQuery, recline.View);
|
||
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
// ## SlickGrid Dataset View
|
||
//
|
||
// Provides a tabular view on a Dataset, based on SlickGrid.
|
||
//
|
||
// https://github.com/mleibman/SlickGrid
|
||
//
|
||
// Initialize it with a `recline.Model.Dataset`.
|
||
//
|
||
// Additional options to drive SlickGrid grid can be given through state.
|
||
// The following keys allow for customization:
|
||
// * gridOptions: to add options at grid level
|
||
// * columnsEditor: to add editor for editable columns
|
||
//
|
||
// For example:
|
||
// var grid = new recline.View.SlickGrid({
|
||
// model: dataset,
|
||
// el: $el,
|
||
// state: {
|
||
// gridOptions: {editable: true},
|
||
// columnsEditor: [
|
||
// {column: 'date', editor: Slick.Editors.Date },
|
||
// {column: 'title', editor: Slick.Editors.Text}
|
||
// ]
|
||
// }
|
||
// });
|
||
//// NB: you need an explicit height on the element for slickgrid to work
|
||
my.SlickGrid = Backbone.View.extend({
|
||
initialize: function(modelEtc) {
|
||
var self = this;
|
||
this.$el.addClass('recline-slickgrid');
|
||
_.bindAll(this, 'render', 'onRecordChanged');
|
||
this.listenTo(this.model.records, 'add remove reset', this.render);
|
||
this.listenTo(this.model.records, 'change', this.onRecordChanged);
|
||
|
||
var state = _.extend({
|
||
hiddenColumns: [],
|
||
columnsOrder: [],
|
||
columnsSort: {},
|
||
columnsWidth: [],
|
||
columnsEditor: [],
|
||
options: {},
|
||
fitColumns: false
|
||
}, modelEtc.state
|
||
|
||
);
|
||
this.state = new recline.Model.ObjectState(state);
|
||
|
||
this._slickHandler = new Slick.EventHandler();
|
||
},
|
||
|
||
events: {
|
||
},
|
||
|
||
onRecordChanged: function(record) {
|
||
// Ignore if the grid is not yet drawn
|
||
if (!this.grid) {
|
||
return;
|
||
}
|
||
|
||
// Let's find the row corresponding to the index
|
||
var row_index = this.grid.getData().getModelRow( record );
|
||
this.grid.invalidateRow(row_index);
|
||
this.grid.getData().updateItem(record, row_index);
|
||
this.grid.render();
|
||
},
|
||
|
||
render: function() {
|
||
var self = this;
|
||
|
||
var options = _.extend({
|
||
enableCellNavigation: true,
|
||
enableColumnReorder: true,
|
||
explicitInitialization: true,
|
||
syncColumnCellResize: true,
|
||
forceFitColumns: this.state.get('fitColumns')
|
||
}, self.state.get('gridOptions'));
|
||
|
||
// We need all columns, even the hidden ones, to show on the column picker
|
||
var columns = [];
|
||
// custom formatter as default one escapes html
|
||
// plus this way we distinguish between rendering/formatting and computed value (so e.g. sort still works ...)
|
||
// row = row index, cell = cell index, value = value, columnDef = column definition, dataContext = full row values
|
||
var formatter = function(row, cell, value, columnDef, dataContext) {
|
||
var field = self.model.fields.get(columnDef.id);
|
||
if (field.renderer) {
|
||
return field.renderer(value, field, dataContext);
|
||
} else {
|
||
return value;
|
||
}
|
||
};
|
||
_.each(this.model.fields.toJSON(),function(field){
|
||
var column = {
|
||
id: field.id,
|
||
name: field.label,
|
||
field: field.id,
|
||
sortable: true,
|
||
minWidth: 80,
|
||
formatter: formatter
|
||
};
|
||
|
||
var widthInfo = _.find(self.state.get('columnsWidth'),function(c){return c.column === field.id;});
|
||
if (widthInfo){
|
||
column.width = widthInfo.width;
|
||
}
|
||
|
||
var editInfo = _.find(self.state.get('columnsEditor'),function(c){return c.column === field.id;});
|
||
if (editInfo){
|
||
column.editor = editInfo.editor;
|
||
}
|
||
columns.push(column);
|
||
});
|
||
|
||
// Restrict the visible columns
|
||
var visibleColumns = _.filter(columns, function(column) {
|
||
return _.indexOf(self.state.get('hiddenColumns'), column.id) === -1;
|
||
});
|
||
|
||
// Order them if there is ordering info on the state
|
||
if (this.state.get('columnsOrder') && this.state.get('columnsOrder').length > 0) {
|
||
visibleColumns = visibleColumns.sort(function(a,b){
|
||
return _.indexOf(self.state.get('columnsOrder'),a.id) > _.indexOf(self.state.get('columnsOrder'),b.id) ? 1 : -1;
|
||
});
|
||
columns = columns.sort(function(a,b){
|
||
return _.indexOf(self.state.get('columnsOrder'),a.id) > _.indexOf(self.state.get('columnsOrder'),b.id) ? 1 : -1;
|
||
});
|
||
}
|
||
|
||
// Move hidden columns to the end, so they appear at the bottom of the
|
||
// column picker
|
||
var tempHiddenColumns = [];
|
||
for (var i = columns.length -1; i >= 0; i--){
|
||
if (_.indexOf(_.pluck(visibleColumns,'id'),columns[i].id) === -1){
|
||
tempHiddenColumns.push(columns.splice(i,1)[0]);
|
||
}
|
||
}
|
||
columns = columns.concat(tempHiddenColumns);
|
||
|
||
// Transform a model object into a row
|
||
function toRow(m) {
|
||
var row = {};
|
||
self.model.fields.each(function(field){
|
||
row[field.id] = m.getFieldValueUnrendered(field);
|
||
});
|
||
return row;
|
||
}
|
||
|
||
function RowSet() {
|
||
var models = [];
|
||
var rows = [];
|
||
|
||
this.push = function(model, row) {
|
||
models.push(model);
|
||
rows.push(row);
|
||
};
|
||
|
||
this.getLength = function() {return rows.length; };
|
||
this.getItem = function(index) {return rows[index];};
|
||
this.getItemMetadata = function(index) {return {};};
|
||
this.getModel = function(index) {return models[index];};
|
||
this.getModelRow = function(m) {return _.indexOf(models, m);};
|
||
this.updateItem = function(m,i) {
|
||
rows[i] = toRow(m);
|
||
models[i] = m;
|
||
};
|
||
}
|
||
|
||
var data = new RowSet();
|
||
|
||
this.model.records.each(function(doc){
|
||
data.push(doc, toRow(doc));
|
||
});
|
||
|
||
this.grid = new Slick.Grid(this.el, data, visibleColumns, options);
|
||
|
||
// Column sorting
|
||
var sortInfo = this.model.queryState.get('sort');
|
||
if (sortInfo){
|
||
var column = sortInfo[0].field;
|
||
var sortAsc = sortInfo[0].order !== 'desc';
|
||
this.grid.setSortColumn(column, sortAsc);
|
||
}
|
||
|
||
this._slickHandler.subscribe(this.grid.onSort, function(e, args){
|
||
var order = (args.sortAsc) ? 'asc':'desc';
|
||
var sort = [{
|
||
field: args.sortCol.field,
|
||
order: order
|
||
}];
|
||
self.model.query({sort: sort});
|
||
});
|
||
|
||
this._slickHandler.subscribe(this.grid.onColumnsReordered, function(e, args){
|
||
self.state.set({columnsOrder: _.pluck(self.grid.getColumns(),'id')});
|
||
});
|
||
|
||
this.grid.onColumnsResized.subscribe(function(e, args){
|
||
var columns = args.grid.getColumns();
|
||
var defaultColumnWidth = args.grid.getOptions().defaultColumnWidth;
|
||
var columnsWidth = [];
|
||
_.each(columns,function(column){
|
||
if (column.width != defaultColumnWidth){
|
||
columnsWidth.push({column:column.id,width:column.width});
|
||
}
|
||
});
|
||
self.state.set({columnsWidth:columnsWidth});
|
||
});
|
||
|
||
this._slickHandler.subscribe(this.grid.onCellChange, function (e, args) {
|
||
// We need to change the model associated value
|
||
//
|
||
var grid = args.grid;
|
||
var model = data.getModel(args.row);
|
||
var field = grid.getColumns()[args.cell].id;
|
||
var v = {};
|
||
v[field] = args.item[field];
|
||
model.set(v);
|
||
});
|
||
|
||
var columnpicker = new Slick.Controls.ColumnPicker(columns, this.grid,
|
||
_.extend(options,{state:this.state}));
|
||
|
||
if (self.visible){
|
||
self.grid.init();
|
||
self.rendered = true;
|
||
} else {
|
||
// Defer rendering until the view is visible
|
||
self.rendered = false;
|
||
}
|
||
|
||
return this;
|
||
},
|
||
|
||
remove: function () {
|
||
this._slickHandler.unsubscribeAll();
|
||
Backbone.View.prototype.remove.apply(this, arguments);
|
||
},
|
||
|
||
show: function() {
|
||
// If the div is hidden, SlickGrid will calculate wrongly some
|
||
// sizes so we must render it explicitly when the view is visible
|
||
if (!this.rendered){
|
||
if (!this.grid){
|
||
this.render();
|
||
}
|
||
this.grid.init();
|
||
this.rendered = true;
|
||
}
|
||
this.visible = true;
|
||
},
|
||
|
||
hide: function() {
|
||
this.visible = false;
|
||
}
|
||
});
|
||
|
||
})(jQuery, recline.View);
|
||
|
||
/*
|
||
* Context menu for the column picker, adapted from
|
||
* http://mleibman.github.com/SlickGrid/examples/example-grouping
|
||
*
|
||
*/
|
||
(function ($) {
|
||
function SlickColumnPicker(columns, grid, options) {
|
||
var $menu;
|
||
var columnCheckboxes;
|
||
|
||
var defaults = {
|
||
fadeSpeed:250
|
||
};
|
||
|
||
function init() {
|
||
grid.onHeaderContextMenu.subscribe(handleHeaderContextMenu);
|
||
options = $.extend({}, defaults, options);
|
||
|
||
$menu = $('<ul class="dropdown-menu slick-contextmenu" style="display:none;position:absolute;z-index:20;" />').appendTo(document.body);
|
||
|
||
$menu.bind('mouseleave', function (e) {
|
||
$(this).fadeOut(options.fadeSpeed);
|
||
});
|
||
$menu.bind('click', updateColumn);
|
||
|
||
}
|
||
|
||
function handleHeaderContextMenu(e, args) {
|
||
e.preventDefault();
|
||
$menu.empty();
|
||
columnCheckboxes = [];
|
||
|
||
var $li, $input;
|
||
for (var i = 0; i < columns.length; i++) {
|
||
$li = $('<li />').appendTo($menu);
|
||
$input = $('<input type="checkbox" />').data('column-id', columns[i].id).attr('id','slick-column-vis-'+columns[i].id);
|
||
columnCheckboxes.push($input);
|
||
|
||
if (grid.getColumnIndex(columns[i].id) !== null) {
|
||
$input.attr('checked', 'checked');
|
||
}
|
||
$input.appendTo($li);
|
||
$('<label />')
|
||
.text(columns[i].name)
|
||
.attr('for','slick-column-vis-'+columns[i].id)
|
||
.appendTo($li);
|
||
}
|
||
$('<li/>').addClass('divider').appendTo($menu);
|
||
$li = $('<li />').data('option', 'autoresize').appendTo($menu);
|
||
$input = $('<input type="checkbox" />').data('option', 'autoresize').attr('id','slick-option-autoresize');
|
||
$input.appendTo($li);
|
||
$('<label />')
|
||
.text('Force fit columns')
|
||
.attr('for','slick-option-autoresize')
|
||
.appendTo($li);
|
||
if (grid.getOptions().forceFitColumns) {
|
||
$input.attr('checked', 'checked');
|
||
}
|
||
|
||
$menu.css('top', e.pageY - 10)
|
||
.css('left', e.pageX - 10)
|
||
.fadeIn(options.fadeSpeed);
|
||
}
|
||
|
||
function updateColumn(e) {
|
||
var checkbox;
|
||
|
||
if ($(e.target).data('option') === 'autoresize') {
|
||
var checked;
|
||
if ($(e.target).is('li')){
|
||
checkbox = $(e.target).find('input').first();
|
||
checked = !checkbox.is(':checked');
|
||
checkbox.attr('checked',checked);
|
||
} else {
|
||
checked = e.target.checked;
|
||
}
|
||
|
||
if (checked) {
|
||
grid.setOptions({forceFitColumns:true});
|
||
grid.autosizeColumns();
|
||
} else {
|
||
grid.setOptions({forceFitColumns:false});
|
||
}
|
||
options.state.set({fitColumns:checked});
|
||
return;
|
||
}
|
||
|
||
if (($(e.target).is('li') && !$(e.target).hasClass('divider')) ||
|
||
$(e.target).is('input')) {
|
||
if ($(e.target).is('li')){
|
||
checkbox = $(e.target).find('input').first();
|
||
checkbox.attr('checked',!checkbox.is(':checked'));
|
||
}
|
||
var visibleColumns = [];
|
||
var hiddenColumnsIds = [];
|
||
$.each(columnCheckboxes, function (i, e) {
|
||
if ($(this).is(':checked')) {
|
||
visibleColumns.push(columns[i]);
|
||
} else {
|
||
hiddenColumnsIds.push(columns[i].id);
|
||
}
|
||
});
|
||
|
||
if (!visibleColumns.length) {
|
||
$(e.target).attr('checked', 'checked');
|
||
return;
|
||
}
|
||
|
||
grid.setColumns(visibleColumns);
|
||
options.state.set({hiddenColumns:hiddenColumnsIds});
|
||
}
|
||
}
|
||
init();
|
||
}
|
||
|
||
// Slick.Controls.ColumnPicker
|
||
$.extend(true, window, { Slick:{ Controls:{ ColumnPicker:SlickColumnPicker }}});
|
||
})(jQuery);
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
// turn off unnecessary logging from VMM Timeline
|
||
if (typeof VMM !== 'undefined') {
|
||
VMM.debug = false;
|
||
}
|
||
|
||
// ## Timeline
|
||
//
|
||
// Timeline view using http://timeline.verite.co/
|
||
my.Timeline = Backbone.View.extend({
|
||
template: ' \
|
||
<div class="recline-timeline"> \
|
||
<div id="vmm-timeline-id"></div> \
|
||
</div> \
|
||
',
|
||
|
||
// These are the default (case-insensitive) names of field that are used if found.
|
||
// If not found, the user will need to define these fields on initialization
|
||
startFieldNames: ['date','startdate', 'start', 'start-date'],
|
||
endFieldNames: ['end','endDate'],
|
||
elementId: '#vmm-timeline-id',
|
||
|
||
initialize: function(options) {
|
||
var self = this;
|
||
this.timeline = new VMM.Timeline();
|
||
this._timelineIsInitialized = false;
|
||
this.listenTo(this.model.fields, 'reset', function() {
|
||
self._setupTemporalField();
|
||
});
|
||
this.listenTo(this.model.records, 'all', function() {
|
||
self.reloadData();
|
||
});
|
||
var stateData = _.extend({
|
||
startField: null,
|
||
endField: null,
|
||
timelineJSOptions: {}
|
||
},
|
||
options.state
|
||
);
|
||
this.state = new recline.Model.ObjectState(stateData);
|
||
this._setupTemporalField();
|
||
},
|
||
|
||
render: function() {
|
||
var tmplData = {};
|
||
var htmls = Mustache.render(this.template, tmplData);
|
||
this.$el.html(htmls);
|
||
// can only call _initTimeline once view in DOM as Timeline uses $
|
||
// internally to look up element
|
||
if ($(this.elementId).length > 0) {
|
||
this._initTimeline();
|
||
}
|
||
},
|
||
|
||
show: function() {
|
||
// only call _initTimeline once view in DOM as Timeline uses $ internally to look up element
|
||
if (this._timelineIsInitialized === false) {
|
||
this._initTimeline();
|
||
}
|
||
},
|
||
|
||
_initTimeline: function() {
|
||
var $timeline = this.$el.find(this.elementId);
|
||
var data = this._timelineJSON();
|
||
this.timeline.init(data, this.elementId, this.state.get("timelineJSOptions"));
|
||
this._timelineIsInitialized = true
|
||
},
|
||
|
||
reloadData: function() {
|
||
if (this._timelineIsInitialized) {
|
||
var data = this._timelineJSON();
|
||
this.timeline.reload(data);
|
||
}
|
||
},
|
||
|
||
// Convert record to JSON for timeline
|
||
//
|
||
// Designed to be overridden in client apps
|
||
convertRecord: function(record, fields) {
|
||
return this._convertRecord(record, fields);
|
||
},
|
||
|
||
// Internal method to generate a Timeline formatted entry
|
||
_convertRecord: function(record, fields) {
|
||
var start = this._parseDate(record.get(this.state.get('startField')));
|
||
var end = this._parseDate(record.get(this.state.get('endField')));
|
||
if (start) {
|
||
var tlEntry = {
|
||
"startDate": start,
|
||
"endDate": end,
|
||
"headline": String(record.get('title') || ''),
|
||
"text": record.get('description') || record.summary()
|
||
};
|
||
return tlEntry;
|
||
} else {
|
||
return null;
|
||
}
|
||
},
|
||
|
||
_timelineJSON: function() {
|
||
var self = this;
|
||
var out = {
|
||
'timeline': {
|
||
'type': 'default',
|
||
'headline': '',
|
||
'date': [
|
||
]
|
||
}
|
||
};
|
||
this.model.records.each(function(record) {
|
||
var newEntry = self.convertRecord(record, self.fields);
|
||
if (newEntry) {
|
||
out.timeline.date.push(newEntry);
|
||
}
|
||
});
|
||
// if no entries create a placeholder entry to prevent Timeline crashing with error
|
||
if (out.timeline.date.length === 0) {
|
||
var tlEntry = {
|
||
"startDate": '2000,1,1',
|
||
"headline": 'No data to show!'
|
||
};
|
||
out.timeline.date.push(tlEntry);
|
||
}
|
||
return out;
|
||
},
|
||
|
||
_parseDate: function(date) {
|
||
if (!date) {
|
||
return null;
|
||
}
|
||
var out = $.trim(date);
|
||
out = out.replace(/(\d)th/g, '$1');
|
||
out = out.replace(/(\d)st/g, '$1');
|
||
out = $.trim(out) ? moment(out) : null;
|
||
if (out && out.isValid()) {
|
||
return out.toDate();
|
||
} else {
|
||
return null;
|
||
}
|
||
},
|
||
|
||
_setupTemporalField: function() {
|
||
this.state.set({
|
||
startField: this._checkField(this.startFieldNames),
|
||
endField: this._checkField(this.endFieldNames)
|
||
});
|
||
},
|
||
|
||
_checkField: function(possibleFieldNames) {
|
||
var modelFieldNames = this.model.fields.pluck('id');
|
||
for (var i = 0; i < possibleFieldNames.length; i++){
|
||
for (var j = 0; j < modelFieldNames.length; j++){
|
||
if (modelFieldNames[j].toLowerCase() == possibleFieldNames[i].toLowerCase())
|
||
return modelFieldNames[j];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
});
|
||
|
||
})(jQuery, recline.View);
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
|
||
// ## FacetViewer
|
||
//
|
||
// Widget for displaying facets
|
||
//
|
||
// Usage:
|
||
//
|
||
// var viewer = new FacetViewer({
|
||
// model: dataset
|
||
// });
|
||
my.FacetViewer = Backbone.View.extend({
|
||
className: 'recline-facet-viewer',
|
||
template: ' \
|
||
<div class="facets"> \
|
||
{{#facets}} \
|
||
<div class="facet-summary" data-facet="{{id}}"> \
|
||
<h3> \
|
||
{{id}} \
|
||
</h3> \
|
||
<ul class="facet-items"> \
|
||
{{#terms}} \
|
||
<li><a class="facet-choice js-facet-filter" data-value="{{term}}" href="#{{term}}">{{term}} ({{count}})</a></li> \
|
||
{{/terms}} \
|
||
{{#entries}} \
|
||
<li><a class="facet-choice js-facet-filter" data-value="{{time}}">{{term}} ({{count}})</a></li> \
|
||
{{/entries}} \
|
||
</ul> \
|
||
</div> \
|
||
{{/facets}} \
|
||
</div> \
|
||
',
|
||
|
||
events: {
|
||
'click .js-facet-filter': 'onFacetFilter'
|
||
},
|
||
initialize: function(model) {
|
||
_.bindAll(this, 'render');
|
||
this.listenTo(this.model.facets, 'all', this.render);
|
||
this.listenTo(this.model.fields, 'all', this.render);
|
||
this.render();
|
||
},
|
||
render: function() {
|
||
var tmplData = {
|
||
fields: this.model.fields.toJSON()
|
||
};
|
||
tmplData.facets = _.map(this.model.facets.toJSON(), function(facet) {
|
||
if (facet._type === 'date_histogram') {
|
||
facet.entries = _.map(facet.entries, function(entry) {
|
||
entry.term = new Date(entry.time).toDateString();
|
||
return entry;
|
||
});
|
||
}
|
||
return facet;
|
||
});
|
||
var templated = Mustache.render(this.template, tmplData);
|
||
this.$el.html(templated);
|
||
// are there actually any facets to show?
|
||
if (this.model.facets.length > 0) {
|
||
this.$el.show();
|
||
} else {
|
||
this.$el.hide();
|
||
}
|
||
},
|
||
onHide: function(e) {
|
||
e.preventDefault();
|
||
this.$el.hide();
|
||
},
|
||
onFacetFilter: function(e) {
|
||
e.preventDefault();
|
||
var $target= $(e.target);
|
||
var fieldId = $target.closest('.facet-summary').attr('data-facet');
|
||
var value = $target.attr('data-value');
|
||
this.model.queryState.addFilter({type: 'term', field: fieldId, term: value});
|
||
// have to trigger explicitly for some reason
|
||
this.model.query();
|
||
}
|
||
});
|
||
|
||
|
||
})(jQuery, recline.View);
|
||
|
||
/*jshint multistr:true */
|
||
|
||
// Field Info
|
||
//
|
||
// For each field
|
||
//
|
||
// Id / Label / type / format
|
||
|
||
// Editor -- to change type (and possibly format)
|
||
// Editor for show/hide ...
|
||
|
||
// Summaries of fields
|
||
//
|
||
// Top values / number empty
|
||
// If number: max, min average ...
|
||
|
||
// Box to boot transform editor ...
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
|
||
my.Fields = Backbone.View.extend({
|
||
className: 'recline-fields-view',
|
||
template: ' \
|
||
<div class="accordion fields-list well"> \
|
||
<h3>Fields <a href="#" class="js-show-hide">+</a></h3> \
|
||
{{#fields}} \
|
||
<div class="accordion-group field"> \
|
||
<div class="accordion-heading"> \
|
||
<i class="icon-file"></i> \
|
||
<h4> \
|
||
{{label}} \
|
||
<small> \
|
||
{{type}} \
|
||
<a class="accordion-toggle" data-toggle="collapse" href="#collapse{{id}}"> » </a> \
|
||
</small> \
|
||
</h4> \
|
||
</div> \
|
||
<div id="collapse{{id}}" class="accordion-body collapse"> \
|
||
<div class="accordion-inner"> \
|
||
{{#facets}} \
|
||
<div class="facet-summary" data-facet="{{id}}"> \
|
||
<ul class="facet-items"> \
|
||
{{#terms}} \
|
||
<li class="facet-item"><span class="term">{{term}}</span> <span class="count">[{{count}}]</span></li> \
|
||
{{/terms}} \
|
||
</ul> \
|
||
</div> \
|
||
{{/facets}} \
|
||
<div class="clear"></div> \
|
||
</div> \
|
||
</div> \
|
||
</div> \
|
||
{{/fields}} \
|
||
</div> \
|
||
',
|
||
|
||
initialize: function(model) {
|
||
var self = this;
|
||
_.bindAll(this, 'render');
|
||
|
||
// TODO: this is quite restrictive in terms of when it is re-run
|
||
// e.g. a change in type will not trigger a re-run atm.
|
||
// being more liberal (e.g. binding to all) can lead to being called a lot (e.g. for change:width)
|
||
this.listenTo(this.model.fields, 'reset', function(action) {
|
||
self.model.fields.each(function(field) {
|
||
field.facets.unbind('all', self.render);
|
||
field.facets.bind('all', self.render);
|
||
});
|
||
// fields can get reset or changed in which case we need to recalculate
|
||
self.model.getFieldsSummary();
|
||
self.render();
|
||
});
|
||
this.$el.find('.collapse').collapse();
|
||
this.render();
|
||
},
|
||
render: function() {
|
||
var self = this;
|
||
var tmplData = {
|
||
fields: []
|
||
};
|
||
this.model.fields.each(function(field) {
|
||
var out = field.toJSON();
|
||
out.facets = field.facets.toJSON();
|
||
tmplData.fields.push(out);
|
||
});
|
||
var templated = Mustache.render(this.template, tmplData);
|
||
this.$el.html(templated);
|
||
}
|
||
});
|
||
|
||
})(jQuery, recline.View);
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
|
||
my.FilterEditor = Backbone.View.extend({
|
||
className: 'recline-filter-editor well',
|
||
template: ' \
|
||
<div class="filters"> \
|
||
<h3>Filters</h3> \
|
||
<a href="#" class="js-add-filter">Add filter</a> \
|
||
<form class="form-stacked js-add" style="display: none;"> \
|
||
<fieldset> \
|
||
<label>Field</label> \
|
||
<select class="fields"> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
<label>Filter type</label> \
|
||
<select class="filterType"> \
|
||
<option value="term">Value</option> \
|
||
<option value="range">Range</option> \
|
||
<option value="geo_distance">Geo distance</option> \
|
||
</select> \
|
||
<button type="submit" class="btn">Add</button> \
|
||
</fieldset> \
|
||
</form> \
|
||
<form class="form-stacked js-edit"> \
|
||
{{#filters}} \
|
||
{{{filterRender}}} \
|
||
{{/filters}} \
|
||
{{#filters.length}} \
|
||
<button type="submit" class="btn">Update</button> \
|
||
{{/filters.length}} \
|
||
</form> \
|
||
</div> \
|
||
',
|
||
filterTemplates: {
|
||
term: ' \
|
||
<div class="filter-{{type}} filter"> \
|
||
<fieldset> \
|
||
<legend> \
|
||
{{field}} <small>{{type}}</small> \
|
||
<a class="js-remove-filter" href="#" title="Remove this filter" data-filter-id="{{id}}">×</a> \
|
||
</legend> \
|
||
<input type="text" value="{{term}}" name="term" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
</fieldset> \
|
||
</div> \
|
||
',
|
||
range: ' \
|
||
<div class="filter-{{type}} filter"> \
|
||
<fieldset> \
|
||
<legend> \
|
||
{{field}} <small>{{type}}</small> \
|
||
<a class="js-remove-filter" href="#" title="Remove this filter" data-filter-id="{{id}}">×</a> \
|
||
</legend> \
|
||
<label class="control-label" for="">From</label> \
|
||
<input type="text" value="{{start}}" name="start" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
<label class="control-label" for="">To</label> \
|
||
<input type="text" value="{{stop}}" name="stop" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
</fieldset> \
|
||
</div> \
|
||
',
|
||
geo_distance: ' \
|
||
<div class="filter-{{type}} filter"> \
|
||
<fieldset> \
|
||
<legend> \
|
||
{{field}} <small>{{type}}</small> \
|
||
<a class="js-remove-filter" href="#" title="Remove this filter" data-filter-id="{{id}}">×</a> \
|
||
</legend> \
|
||
<label class="control-label" for="">Longitude</label> \
|
||
<input type="text" value="{{point.lon}}" name="lon" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
<label class="control-label" for="">Latitude</label> \
|
||
<input type="text" value="{{point.lat}}" name="lat" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
<label class="control-label" for="">Distance (km)</label> \
|
||
<input type="text" value="{{distance}}" name="distance" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
</fieldset> \
|
||
</div> \
|
||
'
|
||
},
|
||
events: {
|
||
'click .js-remove-filter': 'onRemoveFilter',
|
||
'click .js-add-filter': 'onAddFilterShow',
|
||
'submit form.js-edit': 'onTermFiltersUpdate',
|
||
'submit form.js-add': 'onAddFilter'
|
||
},
|
||
initialize: function() {
|
||
_.bindAll(this, 'render');
|
||
this.listenTo(this.model.fields, 'all', this.render);
|
||
this.listenTo(this.model.queryState, 'change change:filters:new-blank', this.render);
|
||
this.render();
|
||
},
|
||
render: function() {
|
||
var self = this;
|
||
var tmplData = $.extend(true, {}, this.model.queryState.toJSON());
|
||
// we will use idx in list as there id ...
|
||
tmplData.filters = _.map(tmplData.filters, function(filter, idx) {
|
||
filter.id = idx;
|
||
return filter;
|
||
});
|
||
tmplData.fields = this.model.fields.toJSON();
|
||
tmplData.filterRender = function() {
|
||
return Mustache.render(self.filterTemplates[this.type], this);
|
||
};
|
||
var out = Mustache.render(this.template, tmplData);
|
||
this.$el.html(out);
|
||
},
|
||
onAddFilterShow: function(e) {
|
||
e.preventDefault();
|
||
var $target = $(e.target);
|
||
$target.hide();
|
||
this.$el.find('form.js-add').show();
|
||
},
|
||
onAddFilter: function(e) {
|
||
e.preventDefault();
|
||
var $target = $(e.target);
|
||
$target.hide();
|
||
var filterType = $target.find('select.filterType').val();
|
||
var field = $target.find('select.fields').val();
|
||
this.model.queryState.addFilter({type: filterType, field: field});
|
||
},
|
||
onRemoveFilter: function(e) {
|
||
e.preventDefault();
|
||
var $target = $(e.target);
|
||
var filterId = $target.attr('data-filter-id');
|
||
this.model.queryState.removeFilter(filterId);
|
||
},
|
||
onTermFiltersUpdate: function(e) {
|
||
var self = this;
|
||
e.preventDefault();
|
||
var filters = self.model.queryState.get('filters');
|
||
var $form = $(e.target);
|
||
_.each($form.find('input'), function(input) {
|
||
var $input = $(input);
|
||
var filterType = $input.attr('data-filter-type');
|
||
var fieldId = $input.attr('data-filter-field');
|
||
var filterIndex = parseInt($input.attr('data-filter-id'), 10);
|
||
var name = $input.attr('name');
|
||
var value = $input.val();
|
||
|
||
switch (filterType) {
|
||
case 'term':
|
||
filters[filterIndex].term = value;
|
||
break;
|
||
case 'range':
|
||
filters[filterIndex][name] = value;
|
||
break;
|
||
case 'geo_distance':
|
||
if(name === 'distance') {
|
||
filters[filterIndex].distance = parseFloat(value);
|
||
}
|
||
else {
|
||
filters[filterIndex].point[name] = parseFloat(value);
|
||
}
|
||
break;
|
||
}
|
||
});
|
||
self.model.queryState.set({filters: filters, from: 0});
|
||
self.model.queryState.trigger('change');
|
||
}
|
||
});
|
||
|
||
|
||
})(jQuery, recline.View);
|
||
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
|
||
my.Pager = Backbone.View.extend({
|
||
className: 'recline-pager',
|
||
template: ' \
|
||
<div class="pagination"> \
|
||
<ul> \
|
||
<li class="prev action-pagination-update"><a href="">«</a></li> \
|
||
<li class="active"><a><input name="from" type="text" value="{{from}}" /> – <input name="to" type="text" value="{{to}}" /> </a></li> \
|
||
<li class="next action-pagination-update"><a href="">»</a></li> \
|
||
</ul> \
|
||
</div> \
|
||
',
|
||
|
||
events: {
|
||
'click .action-pagination-update': 'onPaginationUpdate',
|
||
'change input': 'onFormSubmit'
|
||
},
|
||
|
||
initialize: function() {
|
||
_.bindAll(this, 'render');
|
||
this.listenTo(this.model, 'change', this.render);
|
||
this.render();
|
||
},
|
||
onFormSubmit: function(e) {
|
||
e.preventDefault();
|
||
var newFrom = parseInt(this.$el.find('input[name="from"]').val());
|
||
var newSize = parseInt(this.$el.find('input[name="to"]').val()) - newFrom;
|
||
newFrom = Math.max(newFrom, 0);
|
||
newSize = Math.max(newSize, 1);
|
||
this.model.set({size: newSize, from: newFrom});
|
||
},
|
||
onPaginationUpdate: function(e) {
|
||
e.preventDefault();
|
||
var $el = $(e.target);
|
||
var newFrom = 0;
|
||
if ($el.parent().hasClass('prev')) {
|
||
newFrom = this.model.get('from') - Math.max(0, this.model.get('size'));
|
||
} else {
|
||
newFrom = this.model.get('from') + this.model.get('size');
|
||
}
|
||
newFrom = Math.max(newFrom, 0);
|
||
this.model.set({from: newFrom});
|
||
},
|
||
render: function() {
|
||
var tmplData = this.model.toJSON();
|
||
tmplData.to = this.model.get('from') + this.model.get('size');
|
||
var templated = Mustache.render(this.template, tmplData);
|
||
this.$el.html(templated);
|
||
}
|
||
});
|
||
|
||
})(jQuery, recline.View);
|
||
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
|
||
my.QueryEditor = Backbone.View.extend({
|
||
className: 'recline-query-editor',
|
||
template: ' \
|
||
<form action="" method="GET" class="form-inline"> \
|
||
<div class="input-prepend text-query"> \
|
||
<span class="add-on"><i class="icon-search"></i></span> \
|
||
<input type="text" name="q" value="{{q}}" class="span2" placeholder="Search data ..." class="search-query" /> \
|
||
</div> \
|
||
<button type="submit" class="btn">Go »</button> \
|
||
</form> \
|
||
',
|
||
|
||
events: {
|
||
'submit form': 'onFormSubmit'
|
||
},
|
||
|
||
initialize: function() {
|
||
_.bindAll(this, 'render');
|
||
this.listenTo(this.model, 'change', this.render);
|
||
this.render();
|
||
},
|
||
onFormSubmit: function(e) {
|
||
e.preventDefault();
|
||
var query = this.$el.find('.text-query input').val();
|
||
this.model.set({q: query});
|
||
},
|
||
render: function() {
|
||
var tmplData = this.model.toJSON();
|
||
var templated = Mustache.render(this.template, tmplData);
|
||
this.$el.html(templated);
|
||
}
|
||
});
|
||
|
||
})(jQuery, recline.View);
|
||
|
||
/*jshint multistr:true */
|
||
|
||
this.recline = this.recline || {};
|
||
this.recline.View = this.recline.View || {};
|
||
|
||
(function($, my) {
|
||
"use strict";
|
||
|
||
my.ValueFilter = Backbone.View.extend({
|
||
className: 'recline-filter-editor well',
|
||
template: ' \
|
||
<div class="filters"> \
|
||
<h3>Filters</h3> \
|
||
<button class="btn js-add-filter add-filter">Add filter</button> \
|
||
<form class="form-stacked js-add" style="display: none;"> \
|
||
<fieldset> \
|
||
<label>Field</label> \
|
||
<select class="fields"> \
|
||
{{#fields}} \
|
||
<option value="{{id}}">{{label}}</option> \
|
||
{{/fields}} \
|
||
</select> \
|
||
<button type="submit" class="btn">Add</button> \
|
||
</fieldset> \
|
||
</form> \
|
||
<form class="form-stacked js-edit"> \
|
||
{{#filters}} \
|
||
{{{filterRender}}} \
|
||
{{/filters}} \
|
||
{{#filters.length}} \
|
||
<button type="submit" class="btn update-filter">Update</button> \
|
||
{{/filters.length}} \
|
||
</form> \
|
||
</div> \
|
||
',
|
||
filterTemplates: {
|
||
term: ' \
|
||
<div class="filter-{{type}} filter"> \
|
||
<fieldset> \
|
||
{{field}} \
|
||
<a class="js-remove-filter" href="#" title="Remove this filter" data-filter-id="{{id}}">×</a> \
|
||
<input type="text" value="{{term}}" name="term" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
|
||
</fieldset> \
|
||
</div> \
|
||
'
|
||
},
|
||
events: {
|
||
'click .js-remove-filter': 'onRemoveFilter',
|
||
'click .js-add-filter': 'onAddFilterShow',
|
||
'submit form.js-edit': 'onTermFiltersUpdate',
|
||
'submit form.js-add': 'onAddFilter'
|
||
},
|
||
initialize: function() {
|
||
_.bindAll(this, 'render');
|
||
this.listenTo(this.model.fields, 'all', this.render);
|
||
this.listenTo(this.model.queryState, 'change change:filters:new-blank', this.render);
|
||
this.render();
|
||
},
|
||
render: function() {
|
||
var self = this;
|
||
var tmplData = $.extend(true, {}, this.model.queryState.toJSON());
|
||
// we will use idx in list as the id ...
|
||
tmplData.filters = _.map(tmplData.filters, function(filter, idx) {
|
||
filter.id = idx;
|
||
return filter;
|
||
});
|
||
tmplData.fields = this.model.fields.toJSON();
|
||
tmplData.filterRender = function() {
|
||
return Mustache.render(self.filterTemplates.term, this);
|
||
};
|
||
var out = Mustache.render(this.template, tmplData);
|
||
this.$el.html(out);
|
||
},
|
||
updateFilter: function(input) {
|
||
var self = this;
|
||
var filters = self.model.queryState.get('filters');
|
||
var $input = $(input);
|
||
var filterIndex = parseInt($input.attr('data-filter-id'), 10);
|
||
var value = $input.val();
|
||
filters[filterIndex].term = value;
|
||
},
|
||
onAddFilterShow: function(e) {
|
||
e.preventDefault();
|
||
var $target = $(e.target);
|
||
$target.hide();
|
||
this.$el.find('form.js-add').show();
|
||
},
|
||
onAddFilter: function(e) {
|
||
e.preventDefault();
|
||
var $target = $(e.target);
|
||
$target.hide();
|
||
var field = $target.find('select.fields').val();
|
||
this.model.queryState.addFilter({type: 'term', field: field});
|
||
},
|
||
onRemoveFilter: function(e) {
|
||
e.preventDefault();
|
||
var $target = $(e.target);
|
||
var filterId = $target.attr('data-filter-id');
|
||
this.model.queryState.removeFilter(filterId);
|
||
},
|
||
onTermFiltersUpdate: function(e) {
|
||
var self = this;
|
||
e.preventDefault();
|
||
var filters = self.model.queryState.get('filters');
|
||
var $form = $(e.target);
|
||
_.each($form.find('input'), function(input) {
|
||
self.updateFilter(input);
|
||
});
|
||
self.model.queryState.set({filters: filters, from: 0});
|
||
self.model.queryState.trigger('change');
|
||
}
|
||
});
|
||
|
||
})(jQuery, recline.View);
|