From 14bd9364db92001d21ab31307e09fbf93c379606 Mon Sep 17 00:00:00 2001 From: Rufus Pollock Date: Wed, 4 Apr 2012 10:59:05 +0100 Subject: [PATCH] [build][m]: build docs and latest version of library. --- docs/backend/base.html | 116 ++++++++++++++++++++++++------ docs/backend/dataproxy.html | 9 +-- docs/backend/elasticsearch.html | 35 +++++++--- docs/backend/gdocs.html | 6 +- docs/backend/localcsv.html | 120 ++++++++++++++++++++++++++++++++ docs/backend/memory.html | 92 ++++++++++++++++++++++-- docs/model.html | 66 +++++++++++++++--- docs/view-flot-graph.html | 114 ++++++++++++++++++------------ docs/view-grid.html | 31 ++++----- docs/view.html | 89 ++++++++++++++++++++--- index.html | 1 + recline.js | 76 +++++++++++++++----- 12 files changed, 607 insertions(+), 148 deletions(-) create mode 100644 docs/backend/localcsv.html diff --git a/docs/backend/base.html b/docs/backend/base.html index b4da1482..61eab0d5 100644 --- a/docs/backend/base.html +++ b/docs/backend/base.html @@ -1,37 +1,107 @@ - base.js

base.js

Recline Backends

+ base.js

base.js

Recline Backends

Backends are connectors to backend data sources and stores

-

This is just the base module containing various convenience methods.

this.recline = this.recline || {};
+

This is just the base module containing a template Base class and convenience methods.

this.recline = this.recline || {};
 this.recline.Backend = this.recline.Backend || {};
 
 (function($, my) {

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);
-  }

wrapInTimeout

+ }

recline.Backend.Base

-

Crude way to catch backend errors -Many of backends use JSONP and so will not get error messages and this is -a crude way to catch those errors.

  my.wrapInTimeout = function(ourFunction) {
-    var dfd = $.Deferred();
-    var timeout = 5000;
-    var timer = setTimeout(function() {
-      dfd.reject({
-        message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds'
+

Base class for backends providing a template and convenience functions. +You do not have to inherit from this class but even when not it does provide guidance on the functions you must implement.

+ +

Note also that while this (and other Backends) are implemented as Backbone models this is just a convenience.

  my.Base = Backbone.Model.extend({

sync

+ +

An implementation of Backbone.sync that will be used to override +Backbone.sync on operations for Datasets and Documents which are using this backend.

+ +

For read-only implementations you will need only to implement read method +for Dataset models (and even this can be a null operation). The read method +should return relevant metadata for the Dataset. We do not require read support +for Documents because they are loaded in bulk by the query method.

+ +

For backends supporting write operations you must implement update and delete support for Document objects.

+ +

All code paths should return an object conforming to the jquery promise API.

    sync: function(method, model, options) {
+    },
+    

query

+ +

Query the backend for documents returning them in bulk. This method will +be used by the Dataset.query method to search the backend for documents, +retrieving the results in bulk.

+ +

@param {recline.model.Dataset} model: Dataset model.

+ +

@param {Object} queryObj: object describing a query (usually produced by +using recline.Model.Query and calling toJSON on it).

+ +

The structure of data in the Query object or +Hash should follow that defined in issue 34. +(Of course, if you are writing your own backend, and hence +have control over the interpretation of the query object, you +can use whatever structure you like).

+ +

@returns {Promise} promise API object. The promise resolve method will +be called on query completion with a QueryResult object.

+ +

A QueryResult has the following structure (modelled closely on +ElasticSearch - see this issue for more +details):

+ +
+{
+  total: // (required) total number of results (can be null)
+  hits: [ // (required) one entry for each result document
+    {
+       _score:   // (optional) match score for document
+       _type: // (optional) document type
+       _source: // (required) document/row object
+    } 
+  ],
+  facets: { // (optional) 
+    // facet results (as per )
+  }
+}
+
    query: function(model, queryObj) {
+    },

convenience method to convert simple set of documents / rows to a QueryResult

    _docsToQueryResult: function(rows) {
+      var hits = _.map(rows, function(row) {
+        return { _source: row };
       });
-    }, timeout);
-    ourFunction.done(function(arguments) {
-        clearTimeout(timer);
-        dfd.resolve(arguments);
-      })
-      .fail(function(arguments) {
-        clearTimeout(timer);
-        dfd.reject(arguments);
-      })
-      ;
-    return dfd.promise();
-  }
+      return {
+        total: null,
+        hits: hits
+      };
+    },

_wrapInTimeout

+ +

Convenience method providing a crude way to catch backend errors on JSONP calls. +Many of backends use JSONP and so will not get error messages and this is +a crude way to catch those errors.

    _wrapInTimeout: function(ourFunction) {
+      var dfd = $.Deferred();
+      var timeout = 5000;
+      var timer = setTimeout(function() {
+        dfd.reject({
+          message: 'Request Error: Backend did not respond after ' + (timeout / 1000) + ' seconds'
+        });
+      }, timeout);
+      ourFunction.done(function(arguments) {
+          clearTimeout(timer);
+          dfd.resolve(arguments);
+        })
+        .fail(function(arguments) {
+          clearTimeout(timer);
+          dfd.reject(arguments);
+        })
+        ;
+      return dfd.promise();
+    }
+  });
+
 }(jQuery, this.recline.Backend));
 
 
\ No newline at end of file diff --git a/docs/backend/dataproxy.html b/docs/backend/dataproxy.html index d232ef91..303d2286 100644 --- a/docs/backend/dataproxy.html +++ b/docs/backend/dataproxy.html @@ -1,4 +1,4 @@ - dataproxy.js

dataproxy.js

this.recline = this.recline || {};
+      dataproxy.js           

dataproxy.js

this.recline = this.recline || {};
 this.recline.Backend = this.recline.Backend || {};
 
 (function($, my) {

DataProxy Backend

@@ -18,7 +18,7 @@
  • format: (optional) csv | xls (defaults to csv if not specified)
  • -

    Note that this is a read-only backend.

      my.DataProxy = Backbone.Model.extend({
    +

    Note that this is a read-only backend.

      my.DataProxy = my.Base.extend({
         defaults: {
           dataproxy_url: 'http://jsonpdataproxy.appspot.com'
         },
    @@ -35,6 +35,7 @@ retrieve)

    } }, query: function(dataset, queryObj) { + var self = this; var base = this.get('dataproxy_url'); var data = { url: dataset.get('url') @@ -47,7 +48,7 @@ retrieve)

    , dataType: 'jsonp' }); var dfd = $.Deferred(); - my.wrapInTimeout(jqxhr).done(function(results) { + this._wrapInTimeout(jqxhr).done(function(results) { if (results.error) { dfd.reject(results.error); } @@ -62,7 +63,7 @@ retrieve)

    }); return tmp; }); - dfd.resolve(_out); + dfd.resolve(self._docsToQueryResult(_out)); }) .fail(function(arguments) { dfd.reject(arguments); diff --git a/docs/backend/elasticsearch.html b/docs/backend/elasticsearch.html index 0b91fa81..bb338392 100644 --- a/docs/backend/elasticsearch.html +++ b/docs/backend/elasticsearch.html @@ -1,4 +1,4 @@ - elasticsearch.js

    elasticsearch.js

    this.recline = this.recline || {};
    +      elasticsearch.js           

    elasticsearch.js

    this.recline = this.recline || {};
     this.recline.Backend = this.recline.Backend || {};
     
     (function($, my) {

    ElasticSearch Backend

    @@ -17,7 +17,7 @@ url

    This should point to the ES type url. E.G. for ES running on localhost:9200 with index twitter and type tweet it would be

    -
    http://localhost:9200/twitter/tweet
      my.ElasticSearch = Backbone.Model.extend({
    +
    http://localhost:9200/twitter/tweet
      my.ElasticSearch = my.Base.extend({
         _getESUrl: function(dataset) {
           var out = dataset.get('elasticsearch_url');
           if (out) return out;
    @@ -37,7 +37,7 @@ localhost:9200 with index twitter and type tweet it would be

    dataType: 'jsonp' }); var dfd = $.Deferred(); - my.wrapInTimeout(jqxhr).done(function(schema) {

    only one top level key in ES = the type so we can ignore it

                var key = _.keys(schema)[0];
    +          this._wrapInTimeout(jqxhr).done(function(schema) {

    only one top level key in ES = the type so we can ignore it

                var key = _.keys(schema)[0];
                 var fieldData = _.map(schema[key].properties, function(dict, fieldName) {
                   dict.id = fieldName;
                   return dict;
    @@ -74,6 +74,17 @@ localhost:9200 with index twitter and type tweet it would be

    } } delete out.q; + }

    now do filters (note the plural)

          if (out.filters && out.filters.length) {
    +        if (!out.filter) {
    +          out.filter = {}
    +        }
    +        if (!out.filter.and) {
    +          out.filter.and = [];
    +        }
    +        out.filter.and = out.filter.and.concat(out.filters);
    +      }
    +      if (out.filters != undefined) {
    +        delete out.filters;
           }
           return out;
         },
    @@ -86,14 +97,16 @@ localhost:9200 with index twitter and type tweet it would be

    data: data, dataType: 'jsonp' }); - var dfd = $.Deferred();

    TODO: fail case

          jqxhr.done(function(results) {
    -        model.docCount = results.hits.total;
    -        var docs = _.map(results.hits.hits, function(result) {
    -          var _out = result._source;
    -          _out.id = result._id;
    -          return _out;
    -        });
    -        dfd.resolve(docs);
    +      var dfd = $.Deferred();

    TODO: fail case

          jqxhr.done(function(results) {
    +        _.each(results.hits.hits, function(hit) {
    +          if (!'id' in hit._source && hit._id) {
    +            hit._source.id = hit._id;
    +          }
    +        })
    +        if (results.facets) {
    +          results.hits.facets = results.facets;
    +        }
    +        dfd.resolve(results.hits);
           });
           return dfd.promise();
         }
    diff --git a/docs/backend/gdocs.html b/docs/backend/gdocs.html
    index c058c6f5..10963ee7 100644
    --- a/docs/backend/gdocs.html
    +++ b/docs/backend/gdocs.html
    @@ -1,4 +1,4 @@
    -      gdocs.js           

    gdocs.js

    this.recline = this.recline || {};
    +      gdocs.js           

    gdocs.js

    this.recline = this.recline || {};
     this.recline.Backend = this.recline.Backend || {};
     
     (function($, my) {

    Google spreadsheet backend

    @@ -14,7 +14,7 @@ var dataset = new recline.Model.Dataset({ }, 'gdocs' ); -
      my.GDoc = Backbone.Model.extend({
    +
      my.GDoc = my.Base.extend({
         getUrl: function(dataset) {
           var url = dataset.get('url');
           if (url.indexOf('feeds/list') != -1) {
    @@ -58,7 +58,7 @@ TODO: factor this out as a common method with other backends

    _.each(_.zip(fields, d), function (x) { obj[x[0]] = x[1]; }) return obj; }); - dfd.resolve(objs); + dfd.resolve(this._docsToQueryResult(objs)); return dfd; }, gdocsToJavascript: function(gdocsSpreadsheet) { diff --git a/docs/backend/localcsv.html b/docs/backend/localcsv.html new file mode 100644 index 00000000..1abf5bf3 --- /dev/null +++ b/docs/backend/localcsv.html @@ -0,0 +1,120 @@ + localcsv.js

    localcsv.js

    this.recline = this.recline || {};
    +this.recline.Backend = this.recline.Backend || {};
    +
    +(function($, my) {
    +  my.loadFromCSVFile = function(file, callback) {
    +    var metadata = {
    +      id: file.name,
    +      file: file
    +    };
    +    var reader = new FileReader();

    TODO

        reader.onload = function(e) {
    +      var dataset = my.csvToDataset(e.target.result);
    +      callback(dataset);
    +    };
    +    reader.onerror = function (e) {
    +      alert('Failed to load file. Code: ' + e.target.error.code);
    +    }
    +    reader.readAsText(file);
    +  };
    +
    +  my.csvToDataset = function(csvString) {
    +    var out = my.parseCSV(csvString);
    +    fields = _.map(out[0], function(cell) {
    +      return { id: cell, label: cell };
    +    });
    +    var data = _.map(out.slice(1), function(row) {
    +      var _doc = {};
    +      _.each(out[0], function(fieldId, idx) {
    +        _doc[fieldId] = row[idx];
    +      });
    +      return _doc;
    +    });
    +    var dataset = recline.Backend.createDataset(data, fields);
    +    return dataset;
    +  }

    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 {Boolean} [trm=false] If set to True leading and trailing whitespace is stripped off of each non-quoted field as it is imported

    + +

    Heavily based on uselesscode's JS CSV parser (MIT Licensed): +thttp://www.uselesscode.org/javascript/csv/

    	my.parseCSV= function(s, trm) {

    Get rid of any trailing \n

    		s = chomp(s);
    +
    +		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 === ',' || 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 ", add it to the field buffer

    				if (cur !== '"') {
    +					field += cur;
    +				} else {
    +					if (!inQuote) {

    We are not in a quote, start a quote

    						inQuote = true;
    +						fieldQuoted = true;
    +					} else {

    Next char is ", this is an escaped "

    						if (s.charAt(i + 1) === '"') {
    +							field += '"';

    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);
    +
    +		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);
    +		}
    +	}
    +
    +
    +}(jQuery, this.recline.Backend));
    +
    +
    \ No newline at end of file diff --git a/docs/backend/memory.html b/docs/backend/memory.html index 8b9fe834..3f03bd6a 100644 --- a/docs/backend/memory.html +++ b/docs/backend/memory.html @@ -1,7 +1,42 @@ - memory.js

    memory.js

    this.recline = this.recline || {};
    +      memory.js           
    my.FieldList=Backbone.Collection.extend({model:my.Field});return}varseries=this.createSeries(); - varoptions=this.graphOptions[this.chartConfig.graphType]; + varoptions=this.getGraphOptions(this.chartConfig.graphType);this.plot=$.plot(this.$graph,series,options); - if(this.chartConfig.graphTypein{'points':'','lines-and-points':''}){ - this.setupTooltips(); - } this.plot.resize(); this.plot.setupGrid(); this.plot.draw(); - }

    varpreviousPoint=null;this.$graph.bind("plothover",function(event,pos,item){if(item){ - if(previousPoint!=item.dataIndex){ - previousPoint=item.dataIndex; + if(previousPoint!=item.datapoint){ + previousPoint=item.datapoint;$("#flot-tooltip").remove(); - varx=item.datapoint[0].toFixed(2), - y=item.datapoint[1].toFixed(2); + varx=item.datapoint[0]; + vary=item.datapoint[1];vary=doc.get(field);if(typeofx==='string'){x=index; + }model:this.model.queryState});this.el.find('.header').append(queryEditor.el); + varqueryFacetEditor=newmy.FacetViewer({ + model:this.model + }); + this.el.find('.header').append(queryFacetEditor.el);},setupRouting:function(){ - varself=this;}}); +my.FacetViewer=Backbone.View.extend({ + className:'recline-facet-viewer well', + template:' \ + <a class="close js-hide" href="#">&times;</a> \ + <div class="facets row"> \ + <div class="span1"> \ + <h3>Facets</h3> \ + </div> \ + {{#facets}} \ + <div class="facet-summary span2 dropdown" data-facet="{{id}}"> \ + <a class="btn dropdown-toggle" data-toggle="dropdown" href="#"><i class="icon-chevron-down"></i> {{id}} {{label}}</a> \ + <ul class="facet-items dropdown-menu"> \ + {{#terms}} \ + <li><input type="checkbox" class="facet-choice js-facet-filter" value="{{term}}" name="{{term}}" /> <label for="{{term}}">{{term}} ({{count}})</label></li> \ + {{/terms}} \ + </ul> \ + </div> \ + {{/facets}} \ + </div> \ + ', -/* ========================================================== */query:parsed[2]||''}} -}if(q&&q.length&&q[0]==='?'){q=q.slice(1);} - while(e=r.exec(q)){returnqueryString;} +my.getNewHashForQueryString=function(queryParams){ + varqueryPart=my.composeQueryString(queryParams); + if(window.location.hash){});},1000);} -}

    memory.js

    this.recline = this.recline || {};
     this.recline.Backend = this.recline.Backend || {};
     
    -(function($, my) {

    Memory Backend - uses in-memory data

    +(function($, my) {

    createDataset

    + +

    Convenience function to create a simple 'in-memory' dataset in one step.

    + +

    @param data: list of hashes for each document/row in the data ({key: +value, key: value}) +@param fields: (optional) list of field hashes (each hash defining a hash +as per recline.Model.Field). If fields not specified they will be taken +from the data. +@param metadata: (optional) dataset metadata - see recline.Model.Dataset. +If not defined (or id not provided) id will be autogenerated.

      my.createDataset = function(data, fields, metadata) {
    +    if (!metadata) {
    +      var metadata = {};
    +    }
    +    if (!metadata.id) {
    +      metadata.id = String(Math.floor(Math.random() * 100000000) + 1);
    +    }
    +    var backend = recline.Model.backends['memory'];
    +    var datasetInfo = {
    +      documents: data,
    +      metadata: metadata
    +    };
    +    if (fields) {
    +      datasetInfo.fields = fields;
    +    } else {
    +      if (data) {
    +        datasetInfo.fields = _.map(data[0], function(value, key) {
    +          return {id: key};
    +        });
    +      }
    +    }
    +    backend.addDataset(datasetInfo);
    +    var dataset = new recline.Model.Dataset({id: metadata.id}, 'memory');
    +    dataset.fetch();
    +    return dataset;
    +  };

    Memory Backend - uses in-memory data

    To use it you should provide in your constructor data:

    @@ -30,7 +65,7 @@ var dataset = Dataset({id: 'my-id'}, 'memory'); dataset.fetch(); etc ... -

      my.Memory = Backbone.Model.extend({
    + 

      my.Memory = my.Base.extend({
         initialize: function() {
           this.datasets = {};
         },
    @@ -76,19 +111,62 @@
           }
         },
         query: function(model, queryObj) {
    +      var dfd = $.Deferred();
    +      var out = {};
           var numRows = queryObj.size;
           var start = queryObj.from;
    -      var dfd = $.Deferred();
    -      results = this.datasets[model.id].documents;

    not complete sorting!

          _.each(queryObj.sort, function(sortObj) {
    +      results = this.datasets[model.id].documents;
    +      _.each(queryObj.filters, function(filter) {
    +        results = _.filter(results, function(doc) {
    +          var fieldId = _.keys(filter.term)[0];
    +          return (doc[fieldId] == filter.term[fieldId]);
    +        });
    +      });

    not complete sorting!

          _.each(queryObj.sort, function(sortObj) {
             var fieldName = _.keys(sortObj)[0];
             results = _.sortBy(results, function(doc) {
               var _out = doc[fieldName];
               return (sortObj[fieldName].order == 'asc') ? _out : -1*_out;
             });
           });
    -      var results = results.slice(start, start+numRows);
    -      dfd.resolve(results);
    +      out.facets = this._computeFacets(results, queryObj);
    +      var total = results.length;
    +      resultsObj = this._docsToQueryResult(results.slice(start, start+numRows));
    +      _.extend(out, resultsObj);
    +      out.total = total;
    +      dfd.resolve(out);
           return dfd.promise();
    +    },
    +
    +    _computeFacets: function(documents, queryObj) {
    +      var facetResults = {};
    +      if (!queryObj.facets) {
    +        return facetsResults;
    +      }
    +      _.each(queryObj.facets, function(query, facetId) {
    +        facetResults[facetId] = new recline.Model.Facet({id: facetId}).toJSON();
    +        facetResults[facetId].termsall = {};
    +      });

    faceting

          _.each(documents, 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;
         }
       });
       recline.Model.backends['memory'] = new my.Memory();
    diff --git a/docs/model.html b/docs/model.html
    index 3c926946..98c17439 100644
    --- a/docs/model.html
    +++ b/docs/model.html
    @@ -21,9 +21,11 @@ currently loaded for viewing (you update currentDocuments by calling query)
         }
         this.fields = new my.FieldList();
         this.currentDocuments = new my.DocumentList();
    +    this.facets = new my.FacetList();
         this.docCount = null;
         this.queryState = new my.Query();
         this.queryState.bind('change', this.query);
    +    this.queryState.bind('facet:add', this.query);
       },

    query

    AJAX method with promise API to get documents from the backend.

    @@ -33,18 +35,26 @@ updated by queryObj (if provided).

    Resulting DocumentList are used to reset this.currentDocuments and are also returned.

      query: function(queryObj) {
    -    this.trigger('query:start');
         var self = this;
    -    this.queryState.set(queryObj);
    +    this.trigger('query:start');
    +    var actualQuery = self._prepareQuery(queryObj);
         var dfd = $.Deferred();
    -    this.backend.query(this, this.queryState.toJSON()).done(function(rows) {
    -      var docs = _.map(rows, function(row) {
    -        var _doc = new my.Document(row);
    +    this.backend.query(this, actualQuery).done(function(queryResult) {
    +      self.docCount = queryResult.total;
    +      var docs = _.map(queryResult.hits, function(hit) {
    +        var _doc = new my.Document(hit._source);
             _doc.backend = self.backend;
             _doc.dataset = self;
             return _doc;
           });
           self.currentDocuments.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);
    +      }
           self.trigger('query:done');
           dfd.resolve(self.currentDocuments);
         })
    @@ -55,6 +65,14 @@ also returned.

    return dfd.promise(); }, + _prepareQuery: function(newQueryObj) { + if (newQueryObj) { + this.queryState.set(newQueryObj); + } + var out = this.queryState.toJSON(); + return out; + }, + toTemplateJSON: function() { var data = this.toJSON(); data.docCount = this.docCount; @@ -94,11 +112,41 @@ just pass a single argument representing id to the ctor

    A Query object storing Dataset Query state

    my.Query = Backbone.Model.extend({
    -  defaults: {
    -    size: 100
    -    , from: 0
    +  defaults: function() {
    +    return {
    +      size: 100
    +      , from: 0
    +      , facets: {}

    http://www.elasticsearch.org/guide/reference/query-dsl/and-filter.html +, filter: {} +list of simple filters which will be add to 'add' filter of filter

          , filters: []
    +    }
    +  },

    Set (update or add) a terms filter +http://www.elasticsearch.org/guide/reference/query-dsl/terms-filter.html

      addTermFilter: function(fieldId, value) {
    +    var filters = this.get('filters');
    +    var filter = { term: {} };
    +    filter.term[fieldId] = value;
    +    filters.push(filter);
    +    this.set({filters: filters});

    change does not seem to be triggered ...

        this.trigger('change');
    +  },
    +  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);
       }
    -});

    Backend registry

    +});

    A Facet (Result)

    my.Facet = Backbone.Model.extend({
    +  defaults: function() {
    +    return {
    +      _type: 'terms',

    total number of tokens in the facet

          total: 0,

    number of facet values not included in the returned facets

          other: 0,

    number of documents which have no value for the field

          missing: 0,

    term object ({term: , count: ...})

          terms: []
    +    }
    +  }
    +});

    A Collection/List of Facets

    my.FacetList = Backbone.Collection.extend({
    +  model: my.Facet
    +});

    Backend registry

    Backends will register themselves by id into this registry

    my.backends = {};
     
    diff --git a/docs/view-flot-graph.html b/docs/view-flot-graph.html
    index cd3d6080..7ef5b8eb 100644
    --- a/docs/view-flot-graph.html
    +++ b/docs/view-flot-graph.html
    @@ -138,11 +138,9 @@ could be simpler just to have a common template!

    create this.plot and cache it + this.setupTooltips();

    create this.plot and cache it if (!this.plot) { this.plot = $.plot(this.$graph, series, options); } else { @@ -151,42 +149,60 @@ could be simpler just to have a common template!

      },
    -
    -  graphOptions: { 
    -    lines: {
    -       series: { 
    -         lines: { show: true }
    -       }
    -    }
    -    , points: {
    -      series: {
    -        points: { show: true }
    -      },
    -      grid: { hoverable: true, clickable: true }
    -    }
    -    , 'lines-and-points': {
    -      series: {
    -        points: { show: true },
    -        lines: { show: true }
    -      },
    -      grid: { hoverable: true, clickable: true }
    -    }
    -    , bars: {
    -      series: {
    -        lines: {show: false},
    -        bars: {
    -          show: true,
    -          barWidth: 1,
    -          align: "left",
    -          fill: true
    +   }

      },

    needs to be function as can depend on state

      getGraphOptions: function(typeId) { 
    +    var self = this;

    special tickformatter to show labels rather than numbers

        var tickFormatter = function (val) {
    +      if (self.model.currentDocuments.models[val]) {
    +        var out = self.model.currentDocuments.models[val].get(self.chartConfig.group);

    if the value was in fact a number we want that not the

            if (typeof(out) == 'number') {
    +          return val;
    +        } else {
    +          return out;
    +        }
    +      }
    +      return val;
    +    }

    TODO: we should really use tickFormatter and 1 interval ticks if (and +only if) x-axis values are non-numeric +However, that is non-trivial to work out from a dataset (datasets may +have no field type info). Thus at present we only do this for bars.

        var options = { 
    +      lines: {
    +         series: { 
    +           lines: { show: true }
    +         }
    +      }
    +      , points: {
    +        series: {
    +          points: { show: true }
    +        },
    +        grid: { hoverable: true, clickable: true }
    +      }
    +      , 'lines-and-points': {
    +        series: {
    +          points: { show: true },
    +          lines: { show: true }
    +        },
    +        grid: { hoverable: true, clickable: true }
    +      }
    +      , bars: {
    +        series: {
    +          lines: {show: false},
    +          bars: {
    +            show: true,
    +            barWidth: 1,
    +            align: "center",
    +            fill: true,
    +            horizontal: true
    +          }
    +        },
    +        grid: { hoverable: true, clickable: true },
    +        yaxis: {
    +          tickSize: 1,
    +          tickLength: 1,
    +          tickFormatter: tickFormatter,
    +          min: -0.5,
    +          max: self.model.currentDocuments.length - 0.5
             }
    -      },
    -      xaxis: {
    -        tickSize: 1,
    -        tickLength: 1,
           }
         }
    +    return options[typeId];
       },
     
       setupTooltips: function() {
    @@ -207,12 +223,17 @@ could be simpler just to have a common template!

    convert back from 'index' value on x-axis (e.g. in cases where non-number values)

              if (self.model.currentDocuments.models[x]) {
    +            x = self.model.currentDocuments.models[x].get(self.chartConfig.group);
    +          } else {
    +            x = x.toFixed(2);
    +          }
    +          y = y.toFixed(2);
               
               var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', {
                 group: self.chartConfig.group,
    @@ -241,14 +262,17 @@ could be simpler just to have a common template!

    horizontal bar chart

              if (self.chartConfig.graphType == 'bars') {
    +            points.push([y, x]);
    +          } else {
    +            points.push([x, y]);
               }
    -          points.push([x, y]);
             });
             series.push({data: points, label: field});
           });
         }
         return series;
    -  },

    Public: Adds a new empty series select box to the editor.

    + },

    Public: Adds a new empty series select box to the editor.

    All but the first select box will have a remove button that allows them to be removed.

    @@ -264,7 +288,7 @@ to be removed.

    label.append(' [<a href="#remove" class="action-remove-series">Remove</a>]'); label.find('span').text(String.fromCharCode(this.$series.length + 64)); return this; - },

    Public: Removes a series list item from the editor.

    + },

    Public: Removes a series list item from the editor.

    Also updates the labels of the remaining series elements.

      removeSeries: function (e) {
         e.preventDefault();
    @@ -282,7 +306,7 @@ to be removed.

    toggleHelp: function() { this.el.find('.editor-info').toggleClass('editor-hide-info'); - },

    Private: Resets the series property to reference the select elements.

    + },

    Private: Resets the series property to reference the select elements.

    Returns itself.

      _updateSeries: function () {
         this.$series  = this.el.find('.editor-series select');
    diff --git a/docs/view-grid.html b/docs/view-grid.html
    index f5472749..eb67d2e1 100644
    --- a/docs/view-grid.html
    +++ b/docs/view-grid.html
    @@ -64,21 +64,16 @@ Column and row menus

    e.preventDefault(); var actions = { bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.state.currentColumn}) }, + facet: function() { + self.model.queryState.addFacet(self.state.currentColumn); + }, transform: function() { self.showTransformDialog('transform') }, sortAsc: function() { self.setColumnSort('asc') }, sortDesc: function() { self.setColumnSort('desc') }, hideColumn: function() { self.hideColumn() }, - showColumn: function() { self.showColumn(e) },

    TODO: Delete or re-implement ...

          csv: function() { window.location.href = app.csvUrl },
    -      json: function() { window.location.href = "_rewrite/api/json" },
    -      urlImport: function() { showDialog('urlImport') },
    -      pasteImport: function() { showDialog('pasteImport') },
    -      uploadImport: function() { showDialog('uploadImport') },

    END TODO

          deleteColumn: function() {
    -        var msg = "Are you sure? This will delete '" + self.state.currentColumn + "' from all documents.";

    TODO:

            alert('This function needs to be re-implemented');
    -        return;
    -        if (confirm(msg)) costco.deleteColumn(self.state.currentColumn);
    -      },
    +      showColumn: function() { self.showColumn(e) },
           deleteRow: function() {
    -        var doc = _.find(self.model.currentDocuments.models, function(doc) {

    important this is == as the currentRow will be string (as comes + var doc = _.find(self.model.currentDocuments.models, function(doc) {

    important this is == as the currentRow will be string (as comes from DOM) while id may be int

              return doc.id == self.state.currentRow
             });
             doc.destroy().then(function() { 
    @@ -137,7 +132,7 @@ from DOM) while id may be int

    showColumn: function(e) { this.hiddenFields = _.without(this.hiddenFields, $(e.target).data('column')); this.render(); - },

    ======================================================

    + },

    ======================================================

    Templating

      template: ' \
         <table class="data-table table-striped table-condensed" cellspacing="0"> \
    @@ -157,12 +152,12 @@ from DOM) while id may be int

    <th class="column-header {{#hidden}}hidden{{/hidden}}" data-field="{{id}}"> \ <div class="btn-group column-header-menu"> \ <a class="btn dropdown-toggle" data-toggle="dropdown"><i class="icon-cog"></i><span class="caret"></span></a> \ - <ul class="dropdown-menu data-table-menu"> \ - <li class="write-op"><a data-action="bulkEdit" href="JavaScript:void(0);">Transform...</a></li> \ - <li class="write-op"><a data-action="deleteColumn" href="JavaScript:void(0);">Delete this column</a></li> \ + <ul class="dropdown-menu data-table-menu pull-right"> \ + <li><a data-action="facet" href="JavaScript:void(0);">Facet on this Field</a></li> \ <li><a data-action="sortAsc" href="JavaScript:void(0);">Sort ascending</a></li> \ <li><a data-action="sortDesc" href="JavaScript:void(0);">Sort descending</a></li> \ <li><a data-action="hideColumn" href="JavaScript:void(0);">Hide this column</a></li> \ + <li class="write-op"><a data-action="bulkEdit" href="JavaScript:void(0);">Transform...</a></li> \ </ul> \ </div> \ <span class="column-header-name">{{label}}</span> \ @@ -176,7 +171,7 @@ from DOM) while id may be int

    toTemplateJSON: function() { 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 = _.map(this.fields, function(field) { return field.toJSON() });
    +    modelData.notEmpty = ( this.fields.length > 0 )

    TODO: move this sort of thing into a toTemplateJSON method on Dataset?

        modelData.fields = _.map(this.fields, function(field) { return field.toJSON() });
         return modelData;
       },
       render: function() {
    @@ -192,7 +187,7 @@ from DOM) while id may be int

    var newView = new my.DataGridRow({ model: doc, el: tr, - fields: self.fields, + fields: self.fields }, self.options ); @@ -201,7 +196,7 @@ from DOM) while id may be int

    this.el.toggleClass('no-hidden', (self.hiddenFields.length == 0)); return this; } -});

    DataGridRow View for rendering an individual document.

    +});

    DataGridRow View for rendering an individual document.

    Since we want this to update in place it is up to creator to provider the element to attach to.

    @@ -284,7 +279,7 @@ var row = new DataGridRow({ var html = $.mustache(this.template, this.toTemplateJSON()); $(this.el).html(html); return this; - },

    =================== + },

    =================== Cell Editor methods

      onEditClick: function(e) {
         var editing = this.el.find('.data-table-cell-editor-editor');
         if (editing.length > 0) {
    diff --git a/docs/view.html b/docs/view.html
    index 8e0c77f3..daf29400 100644
    --- a/docs/view.html
    +++ b/docs/view.html
    @@ -108,7 +108,8 @@ FlotGraph subview.

    self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); my.notify('Data loaded', {category: 'success'});

    update navigation

            var qs = my.parseHashQueryString();
             qs['reclineQuery'] = JSON.stringify(self.model.queryState.toJSON());
    -        my.setHashQueryString(qs);
    +        var out = my.getNewHashForQueryString(qs);
    +        self.router.navigate(out);
           });
         this.model.bind('query:fail', function(error) {
             my.clearNotifications();
    @@ -158,11 +159,15 @@ note this.model and dataset returned are the same

    Default route

        this.router.route('', this.pageViews[0].id, function() {
    -      self.updateNav(self.pageViews[0].id);
    +    var self = this;

    Default route

        this.router.route(/^(\?.*)?$/, this.pageViews[0].id, function(queryString) {
    +      self.updateNav(self.pageViews[0].id, queryString);
         });
         $.each(this.pageViews, function(idx, view) {
           self.router.route(/^([^?]+)(\?.*)?/, 'view', function(viewId, queryString) {
    @@ -261,8 +266,62 @@ note this.model and dataset returned are the same

    Miscellaneous Utilities

    var urlPathRegex = /^([^?]+)(\?.*)?/;

    Parse the Hash section of a URL into path and query string

    my.parseHashUrl = function(hashUrl) {
    +  events: {
    +    'click .js-hide': 'onHide',
    +    'change .js-facet-filter': 'onFacetFilter'
    +  },
    +  initialize: function(model) {
    +    _.bindAll(this, 'render');
    +    this.el = $(this.el);
    +    this.model.facets.bind('all', this.render);
    +    this.model.fields.bind('all', this.render);
    +    this.render();
    +  },
    +  render: function() {
    +    var tmplData = {
    +      facets: this.model.facets.toJSON(),
    +      fields: this.model.fields.toJSON()
    +    };
    +    var templated = $.mustache(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) {

    todo: uncheck

        var $checkbox = $(e.target);
    +    var fieldId = $checkbox.closest('.facet-summary').attr('data-facet');
    +    var value = $checkbox.val();
    +    this.model.queryState.addTermFilter(fieldId, value);
    +  }
    +});
    +
    +/* ========================================================== */

    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 {};
    @@ -272,7 +331,7 @@ note this.model and dataset returned are the same

    Parse a URL query string (?xyz=abc...) into a dictionary.

    my.parseQueryString = function(q) {
    +}

    Parse a URL query string (?xyz=abc...) into a dictionary.

    my.parseQueryString = function(q) {
       if (!q) {
         return {};
       }
    @@ -285,13 +344,13 @@ note this.model and dataset returned are the same

    TODO: have values be array as query string allow repetition of keys

        urlParams[d(e[1])] = d(e[2]);
    +  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() {
    +}

    Parse the query string out of the URL hash

    my.parseHashQueryString = function() {
       q = my.parseHashUrl(window.location.hash).query;
       return my.parseQueryString(q);
    -}

    Compse a Query String

    my.composeQueryString = function(queryParams) {
    +}

    Compse a Query String

    my.composeQueryString = function(queryParams) {
       var queryString = '?';
       var items = [];
       $.each(queryParams, function(key, value) {
    @@ -301,9 +360,17 @@ note this.model and dataset returned are the same

    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 = window.location.hash.split('?')[0] + my.composeQueryString(queryParams);
    -}

    notify

    + window.location.hash = my.getNewHashForQueryString(queryParams); +}

    notify

    Create a notification (a div.alert in div.alert-messsages) using provide messages and options. Options are:

    @@ -334,7 +401,7 @@ note this.model and dataset returned are the same

    clearNotifications

    +}

    clearNotifications

    Clear all existing notifications

    my.clearNotifications = function() {
       var $notifications = $('.data-explorer .alert-messages .alert');
    diff --git a/index.html b/index.html
    index 80828a33..a246a01f 100644
    --- a/index.html
    +++ b/index.html
    @@ -252,6 +252,7 @@ also easily write your own). Each view holds a pointer to a Dataset:

  • Backend: ElasticSearch
  • Backend: DataProxy (CSV and XLS on the Web)
  • Backend: Google Docs (Spreadsheet)
  • +
  • Backend: Local CSV file
  • Tests

    diff --git a/recline.js b/recline.js index ef842239..73860da4 100644 --- a/recline.js +++ b/recline.js @@ -199,14 +199,27 @@ my.FieldList = Backbone.Collection.extend({ // ## A Query object storing Dataset Query state my.Query = Backbone.Model.extend({ - defaults: { - size: 100 - , from: 0 - , facets: {} + defaults: function() { + return { + size: 100 + , from: 0 + , facets: {} + // http://www.elasticsearch.org/guide/reference/query-dsl/and-filter.html + // , filter: {} + // list of simple filters which will be add to 'add' filter of filter + , filters: [] + } }, // Set (update or add) a terms filter // http://www.elasticsearch.org/guide/reference/query-dsl/terms-filter.html - setFilter: function(fieldId, values) { + addTermFilter: function(fieldId, value) { + var filters = this.get('filters'); + var filter = { term: {} }; + filter.term[fieldId] = value; + filters.push(filter); + this.set({filters: filters}); + // change does not seem to be triggered ... + this.trigger('change'); }, addFacet: function(fieldId) { var facets = this.get('facets'); @@ -225,16 +238,18 @@ my.Query = Backbone.Model.extend({ // ## A Facet (Result) my.Facet = Backbone.Model.extend({ - defaults: { - _type: 'terms', - // total number of tokens in the facet - total: 0, - // number of facet values not included in the returned facets - other: 0, - // number of documents which have no value for the field - missing: 0, - // term object ({term: , count: ...}) - terms: [] + defaults: function() { + return { + _type: 'terms', + // total number of tokens in the facet + total: 0, + // number of facet values not included in the returned facets + other: 0, + // number of documents which have no value for the field + missing: 0, + // term object ({term: , count: ...}) + terms: [] + } } }); @@ -1589,7 +1604,7 @@ my.FacetViewer = Backbone.View.extend({ {{id}} {{label}} \ \
    \ @@ -1598,7 +1613,8 @@ my.FacetViewer = Backbone.View.extend({ ', events: { - 'click .js-hide': 'onHide' + 'click .js-hide': 'onHide', + 'change .js-facet-filter': 'onFacetFilter' }, initialize: function(model) { _.bindAll(this, 'render'); @@ -1624,6 +1640,13 @@ my.FacetViewer = Backbone.View.extend({ onHide: function(e) { e.preventDefault(); this.el.hide(); + }, + onFacetFilter: function(e) { + // todo: uncheck + var $checkbox = $(e.target); + var fieldId = $checkbox.closest('.facet-summary').attr('data-facet'); + var value = $checkbox.val(); + this.model.queryState.addTermFilter(fieldId, value); } }); @@ -2020,6 +2043,19 @@ this.recline.Backend = this.recline.Backend || {}; } delete out.q; } + // now do filters (note the *plural*) + if (out.filters && out.filters.length) { + if (!out.filter) { + out.filter = {} + } + if (!out.filter.and) { + out.filter.and = []; + } + out.filter.and = out.filter.and.concat(out.filters); + } + if (out.filters != undefined) { + delete out.filters; + } return out; }, query: function(model, queryObj) { @@ -2472,6 +2508,12 @@ this.recline.Backend = this.recline.Backend || {}; var numRows = queryObj.size; var start = queryObj.from; results = this.datasets[model.id].documents; + _.each(queryObj.filters, function(filter) { + results = _.filter(results, function(doc) { + var fieldId = _.keys(filter.term)[0]; + return (doc[fieldId] == filter.term[fieldId]); + }); + }); // not complete sorting! _.each(queryObj.sort, function(sortObj) { var fieldName = _.keys(sortObj)[0];