diff --git a/docs/index.html b/docs/index.html index a76db5e84..94b500d52 100644 --- a/docs/index.html +++ b/docs/index.html @@ -62,7 +62,7 @@

Dataset Views and Widgets

  • MultiView View (plus common view code)
  • Grid View (using the excellent Slickgrid)
  • Grid View (no dependencies)
  • -
  • Graph View (based on Flot)
  • +
  • Graph View (based on Flot)
  • Map View (based on Leaflet)
  • Timeline View (using the excellent Verite Timeline)
  • diff --git a/docs/src/backend.csv.html b/docs/src/backend.csv.html index 091b5ace5..e8596ea94 100644 --- a/docs/src/backend.csv.html +++ b/docs/src/backend.csv.html @@ -1,13 +1,14 @@ - backend.csv.js
    Jump To …

    backend.csv.js

    this.recline = this.recline || {};
    +      backend.csv.js           

    backend.csv.js

    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, $) {

    fetch

    +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) {
    +  my.__type__ = 'csv';

    use either jQuery or Underscore Deferred depending on what is available

      var Deferred = _.isUndefined(this.jQuery) ? _.Deferred : jQuery.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. -
    4. dataset.url: a url to an online CSV file that is ajax accessible (note this usually requires either local or on a server that is CORS enabled). The file is then loaded using $.ajax and parsed using the CSV parser (NB: this requires jQuery)
    5. +
    6. 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:

    @@ -19,7 +20,7 @@ useMemoryStore: true }
      my.fetch = function(dataset) {
    -    var dfd = $.Deferred();
    +    var dfd = new Deferred();
         if (dataset.file) {
           var reader = new FileReader();
           var encoding = dataset.encoding || 'UTF-8';
    @@ -44,7 +45,7 @@
             useMemoryStore: true
           });
         } else if (dataset.url) {
    -      $.get(dataset.url).done(function(data) {
    +      jQuery.get(dataset.url).done(function(data) {
             var rows = my.parseCSV(data, dataset);
             dfd.resolve({
               records: rows,
    @@ -53,7 +54,7 @@
           });
         }
         return dfd.promise();
    -  };

    parseCSV

    + };

    parseCSV

    Converts a Comma Separated Values string into an array of arrays. Each line in the CSV becomes an array.

    @@ -74,7 +75,7 @@ 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);
    +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;
    @@ -91,10 +92,10 @@
           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) {
    +      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)) {
    +        }

    Convert unquoted numbers to their appropriate types

            if (rxIsInt.test(field)) {
               field = parseInt(field, 10);
             } else if (rxIsFloat.test(field)) {
               field = parseFloat(field, 10);
    @@ -104,30 +105,30 @@
         };
     
         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") {
    +      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 = '';
    +        }

    Flush the field buffer

            field = '';
             fieldQuoted = false;
    -      } else {

    If it's not a quotechar, add it to the field buffer

            if (cur !== quotechar) {
    +      } 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;
    +          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;
    +          } 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);
    +    }

    Add the last field

        field = processField(field);
         row.push(field);
         out.push(row);
     
         return out;
    -  };

    serializeCSV

    + };

    serializeCSV

    Convert an Object or a simple array of arrays into a Comma Separated Values string.

    @@ -179,9 +180,9 @@ 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);
    +      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;
    @@ -191,12 +192,12 @@
           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)) {
    +        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 = '';
    +        } else {

    Add the current field to the current row

              row += field + delimiter;
    +        }

    Flush the field buffer

            field = '';
           }
         }
     
    @@ -204,10 +205,10 @@
       };
     
       var rxIsInt = /^\d+$/,
    -    rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/,

    If a string has leading or trailing space, + 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) {
    +    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();
             };
    @@ -219,12 +220,12 @@
         }());
     
       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);
    +    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, jQuery));
    +}(this.recline.Backend.CSV));
     
     
    \ No newline at end of file diff --git a/docs/src/backend.dataproxy.html b/docs/src/backend.dataproxy.html index cc30890c2..d52d17db8 100644 --- a/docs/src/backend.dataproxy.html +++ b/docs/src/backend.dataproxy.html @@ -1,10 +1,12 @@ - backend.dataproxy.js

    backend.dataproxy.js

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

    backend.dataproxy.js

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

    URL for the dataproxy

      my.dataproxy_url = 'http://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;

    load

    +(function(my) { + 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 = _.isUndefined(this.jQuery) ? _.Deferred : jQuery.Deferred;

    load

    Load data from a URL via the DataProxy.

    @@ -14,12 +16,12 @@ 'max-results': dataset.size || dataset.rows || 1000, type: dataset.format || '' }; - var jqxhr = $.ajax({ + var jqxhr = jQuery.ajax({ url: my.dataproxy_url, data: data, dataType: 'jsonp' }); - var dfd = $.Deferred(); + var dfd = new Deferred(); _wrapInTimeout(jqxhr).done(function(results) { if (results.error) { dfd.reject(results.error); @@ -31,33 +33,33 @@ useMemoryStore: true }); }) - .fail(function(arguments) { - dfd.reject(arguments); + .fail(function(args) { + dfd.reject(args); }); return dfd.promise(); - };

    _wrapInTimeout

    + };

    _wrapInTimeout

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

      var _wrapInTimeout = function(ourFunction) {
    -    var dfd = $.Deferred();
    +    var 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(arguments) {
    +    ourFunction.done(function(args) {
             clearTimeout(timer);
    -        dfd.resolve(arguments);
    +        dfd.resolve(args);
           })
    -      .fail(function(arguments) {
    +      .fail(function(args) {
             clearTimeout(timer);
    -        dfd.reject(arguments);
    +        dfd.reject(args);
           })
           ;
         return dfd.promise();
    -  }
    +  };
     
    -}(jQuery, this.recline.Backend.DataProxy));
    +}(this.recline.Backend.DataProxy));
     
     
    \ No newline at end of file diff --git a/docs/src/backend.elasticsearch.html b/docs/src/backend.elasticsearch.html index 5927a78d7..614874586 100644 --- a/docs/src/backend.elasticsearch.html +++ b/docs/src/backend.elasticsearch.html @@ -1,9 +1,9 @@ - backend.elasticsearch.js

    backend.elasticsearch.js

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

    backend.elasticsearch.js

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

    ElasticSearch Wrapper

    + my.__type__ = 'elasticsearch';

    use either jQuery or Underscore Deferred depending on what is available

      var Deferred = _.isUndefined(this.jQuery) ? _.Deferred : jQuery.Deferred;

    ElasticSearch Wrapper

    A simple JS wrapper around an ElasticSearch endpoints.

    @@ -23,7 +23,7 @@ this.options = _.extend({ dataType: 'json' }, - options);

    mapping

    + options);

    mapping

    Get ES mapping for this type/table

    @@ -34,7 +34,7 @@ dataType: this.options.dataType }); return jqxhr; - };

    get

    + };

    get

    Get record corresponding to specified id

    @@ -44,7 +44,7 @@ url: base, dataType: 'json' }); - };

    upsert

    + };

    upsert

    create / update a record to ElasticSearch backend

    @@ -61,7 +61,7 @@ data: data, dataType: 'json' }); - };

    delete

    + };

    delete

    Delete a record from the ElasticSearch backend.

    @@ -104,7 +104,7 @@ }); } return out; - },

    convert from Recline sort structure to ES form + },

    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 = {};
    @@ -127,7 +127,7 @@
             out.geo_distance.unit = filter.unit;
           }
           return out;
    -    },

    query

    + },

    query

    @return deferred supporting promise API

        this.query = function(queryObj) {
           var esQuery = (queryObj && queryObj.toJSON) ? queryObj.toJSON() : _.extend({}, queryObj);
    @@ -146,18 +146,18 @@
           });
           return jqxhr;
         }
    -  };

    Recline Connectors

    + };

    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) {
    +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 = $.Deferred();
    +    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];
    +      }

    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;
    @@ -170,10 +170,10 @@
           dfd.reject(arguments);
         });
         return dfd.promise();
    -  };

    save

      my.save = function(changes, dataset) {
    +  };

    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 = $.Deferred();
    +      var dfd = new Deferred();
           msg = 'Saving more than one item at a time not yet supported';
           alert(msg);
           dfd.reject(msg);
    @@ -187,8 +187,8 @@
         } else if (changes.deletes.length > 0) {
           return es.remove(changes.deletes[0].id);
         }
    -  };

    query

      my.query = function(queryObj, dataset) {
    -    var dfd = $.Deferred();
    +  };

    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) {
    @@ -213,7 +213,7 @@
           dfd.reject(out);
         });
         return dfd.promise();
    -  };

    makeRequest

    + };

    makeRequest

    Just $.ajax but in any headers in the 'headers' attribute of this Backend instance. Example:

    diff --git a/docs/src/backend.gdocs.html b/docs/src/backend.gdocs.html index 25c6a7c03..7d07578d2 100644 --- a/docs/src/backend.gdocs.html +++ b/docs/src/backend.gdocs.html @@ -1,9 +1,9 @@ - backend.gdocs.js

    backend.gdocs.js

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

    backend.gdocs.js

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

    Google spreadsheet backend

    +(function(my) { + my.__type__ = 'gdocs';

    use either jQuery or Underscore Deferred depending on what is available

      var Deferred = _.isUndefined(this.jQuery) ? _.Deferred : jQuery.Deferred;

    Google spreadsheet backend

    Fetch data from a Google Docs spreadsheet.

    @@ -29,19 +29,19 @@
  • fields: array of Field objects
  • records: array of objects for each row
  •   my.fetch = function(dataset) {
    -    var dfd  = $.Deferred(); 
    -    var urls = my.getGDocsAPIUrls(dataset.url);

    TODO cover it with tests + var dfd = new Deferred(); + var urls = my.getGDocsAPIUrls(dataset.url);

    TODO cover it with tests get the spreadsheet title

        (function () {
    -      var titleDfd = $.Deferred();
    +      var titleDfd = new Deferred();
     
    -      $.getJSON(urls.spreadsheet, function (d) {
    +      jQuery.getJSON(urls.spreadsheet, function (d) {
               titleDfd.resolve({
                   spreadsheetTitle: d.feed.title.$t
               });
           });
     
           return titleDfd.promise();
    -    }()).then(function (response) {

    get the actual worksheet data

          $.getJSON(urls.worksheet, function(d) {
    +    }()).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};
    @@ -61,7 +61,7 @@
         });
     
         return dfd.promise();
    -  };

    parseData

    + };

    parseData

    Parse data from Google Docs API into a reasonable form

    @@ -79,20 +79,20 @@ }; var entries = gdocsSpreadsheet.feed.entry || []; var key; - var colName;

    percentage values (e.g. 23.3%)

        var rep = /^([\d\.\-]+)\%$/;
    +    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)) {
    +    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) {
    +    }

    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 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];
    @@ -107,20 +107,23 @@
     
         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]+).*/;
    +  };

    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[2]) + 1;
    +        key = matches[1];

    the gid in url is 0-based and feed url is 1-based

            worksheet = parseInt(matches[3]) + 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;
    +    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'
    @@ -129,6 +132,6 @@
     
         return urls;
       };
    -}(jQuery, this.recline.Backend.GDocs));
    +}(this.recline.Backend.GDocs));
     
     
    \ No newline at end of file diff --git a/docs/src/backend.memory.html b/docs/src/backend.memory.html index 3cdd26fcc..5585712e3 100644 --- a/docs/src/backend.memory.html +++ b/docs/src/backend.memory.html @@ -1,49 +1,49 @@ - backend.memory.js

    backend.memory.js

    this.recline = this.recline || {};
    +      backend.memory.js           

    backend.memory.js

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

    Data Wrapper

    +(function(my) { + my.__type__ = 'memory';

    private data - use either jQuery or Underscore Deferred depending on what is available

      var Deferred = _.isUndefined(this.jQuery) ? _.Deferred : jQuery.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 data list of hashes for each record/row in the data ({key: +

    @param records list of hashes for each record/row in the data ({key: value, key: value}) @param fields (optional) list of field hashes (each hash defining a field as per recline.Model.Field). If fields not specified they will be taken -from the data.

      my.Store = function(data, fields) {
    +from the data.

      my.Store = function(records, fields) {
         var self = this;
    -    this.data = data;
    +    this.records = records;

    backwards compatability (in v0.5 records was named data)

        this.data = this.records;
         if (fields) {
           this.fields = fields;
         } else {
    -      if (data) {
    -        this.fields = _.map(data[0], function(value, key) {
    +      if (records) {
    +        this.fields = _.map(records[0], function(value, key) {
               return {id: key, type: 'string'};
             });
           }
         }
     
         this.update = function(doc) {
    -      _.each(self.data, function(internalDoc, idx) {
    +      _.each(self.records, function(internalDoc, idx) {
             if(doc.id === internalDoc.id) {
    -          self.data[idx] = doc;
    +          self.records[idx] = doc;
             }
           });
         };
     
         this.remove = function(doc) {
    -      var newdocs = _.reject(self.data, function(internalDoc) {
    +      var newdocs = _.reject(self.records, function(internalDoc) {
             return (doc.id === internalDoc.id);
           });
    -      this.data = newdocs;
    +      this.records = newdocs;
         };
     
         this.save = function(changes, dataset) {
           var self = this;
    -      var dfd = $.Deferred();

    TODO _.each(changes.creates) { ... }

          _.each(changes.updates, function(record) {
    +      var dfd = new Deferred();

    TODO _.each(changes.creates) { ... }

          _.each(changes.updates, function(record) {
             self.update(record);
           });
           _.each(changes.deletes, function(record) {
    @@ -54,13 +54,13 @@
         },
     
         this.query = function(queryObj) {
    -      var dfd = $.Deferred();
    -      var numRows = queryObj.size || this.data.length;
    +      var dfd = new Deferred();
    +      var numRows = queryObj.size || this.records.length;
           var start = queryObj.from || 0;
    -      var results = this.data;
    +      var results = this.records;
           
           results = this._applyFilters(results, queryObj);
    -      results = this._applyFreeTextQuery(results, queryObj);

    TODO: this is not complete sorting! + 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) {
    @@ -79,8 +79,8 @@
           };
           dfd.resolve(out);
           return dfd.promise();
    -    };

    in place filtering

        this._applyFilters = function(results, queryObj) {
    -      var filters = queryObj.filters;

    register filters

          var filterFunctions = {
    +    };

    in place filtering

        this._applyFilters = function(results, queryObj) {
    +      var filters = queryObj.filters;

    register filters

          var filterFunctions = {
             term         : term,
             range        : range,
             geo_distance : geo_distance
    @@ -88,6 +88,7 @@
           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 new Date(e).valueOf() },
             datetime   : function (e) { return new Date(e).valueOf() }
    @@ -99,11 +100,11 @@
           function getDataParser(filter) {
             var fieldType = keyedFields[filter.field].type || 'string';
             return dataParsers[fieldType];
    -      }

    filter records

          return _.filter(results, function (record) {
    +      }

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

    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);
    @@ -117,15 +118,15 @@
             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 + 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) {
    +      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) {
    @@ -139,10 +140,10 @@
                   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) + } 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) + matches = matches && foundmatch;

    TODO: early out (once false should break to spare unnecessary testing) if (!matches) return false;

              });
               return matches;
             });
    @@ -155,9 +156,9 @@
           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();
    +      _.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) {
    +      });

    faceting

          _.each(records, function(doc) {
             _.each(queryObj.facets, function(query, facetId) {
               var fieldId = query.terms.field;
               var val = doc[fieldId];
    @@ -174,21 +175,14 @@
             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 = _.sortBy(terms, function(item) {

    want descending order

              return -item.count;
             });
             tmp.terms = tmp.terms.slice(0, 10);
           });
           return facetResults;
         };
    -
    -    this.transform = function(editFunc) {
    -      var toUpdate = recline.Data.Transform.mapDocs(this.data, editFunc);

    TODO: very inefficient -- could probably just walk the documents and updates in tandem and update

          _.each(toUpdate.updates, function(record, idx) {
    -        self.data[idx] = record;
    -      });
    -      return this.save(toUpdate);
    -    };
       };
     
    -}(jQuery, this.recline.Backend.Memory));
    +}(this.recline.Backend.Memory));
     
     
    \ No newline at end of file diff --git a/docs/src/ecma-fixes.html b/docs/src/ecma-fixes.html index 212ecf5d5..3df0df090 100644 --- a/docs/src/ecma-fixes.html +++ b/docs/src/ecma-fixes.html @@ -1,4 +1,4 @@ - ecma-fixes.js

    ecma-fixes.js

    This file adds in full array method support in browsers that don't support it + ecma-fixes.js

    ecma-fixes.js

    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;
    diff --git a/docs/src/model.html b/docs/src/model.html
    index a177a4604..80f8bc8f1 100644
    --- a/docs/src/model.html
    +++ b/docs/src/model.html
    @@ -1,10 +1,10 @@
    -      model.js           

    model.js

    Recline Backbone Models

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

    model.js

    Recline Backbone Models

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

    Dataset

    my.Dataset = Backbone.Model.extend({
    +(function(my) {

    use either jQuery or Underscore Deferred depending on what is available

    var Deferred = _.isUndefined(this.jQuery) ? _.Deferred : jQuery.Deferred;

    Dataset

    my.Dataset = Backbone.Model.extend({
       constructor: function Dataset() {
         Backbone.Model.prototype.constructor.apply(this, arguments);
    -  },

    initialize

      initialize: function() {
    +  },

    initialize

      initialize: function() {
         _.bindAll(this, 'query');
         this.backend = null;
         if (this.get('backend')) {
    @@ -25,25 +25,25 @@
         this.recordCount = null;
         this.queryState = new my.Query();
         this.queryState.bind('change', this.query);
    -    this.queryState.bind('facet:add', this.query);

    store is what we query and save against + this.queryState.bind('facet:add', this.query);

    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

    + },

    fetch

    Retrieve dataset and (some) records from the backend.

      fetch: function() {
         var self = this;
    -    var dfd = $.Deferred();
    +    var dfd = new Deferred();
     
         if (this.backend !== recline.Backend.Memory) {
           this.backend.fetch(this.toJSON())
             .done(handleResults)
    -        .fail(function(arguments) {
    -          dfd.reject(arguments);
    +        .fail(function(args) {
    +          dfd.reject(args);
             });
    -    } else {

    special case where we have been given data directly

          handleResults({
    +    } else {

    special case where we have been given data directly

          handleResults({
             records: this.get('records'),
             fields: this.get('fields'),
             useMemoryStore: true
    @@ -62,18 +62,18 @@
             .done(function() {
               dfd.resolve(self);
             })
    -        .fail(function(arguments) {
    -          dfd.reject(arguments);
    +        .fail(function(args) {
    +          dfd.reject(args);
             });
         }
     
         return dfd.promise();
    -  },

    _normalizeRecordsAndFields

    + },

    _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 = [ {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 {
    @@ -81,9 +81,14 @@
               return {id: key};
             });
           }
    -    } 

    fields is an array of strings (i.e. list of field headings/ids)

        if (fields && fields.length > 0 && typeof fields[0] === 'string') {

    Rename duplicate fieldIds as each field name needs to be + }

    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) {

    cannot use trim as not supported by IE7

            var fieldId = field.replace(/^\s+|\s+$/g, '');
    +      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;
    @@ -94,10 +99,10 @@
             }
             if (!(field in seen)) {
               seen[field] = 0;
    -        }

    TODO: decide whether to keep original name as label ... + }

    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 + }

    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 = {};
    @@ -114,19 +119,7 @@
       },
     
       save: function() {
    -    var self = this;

    TODO: need to reset the changes ...

        return this._store.save(this._changes, this.toJSON());
    -  },
    -
    -  transform: function(editFunc) {
    -    var self = this;
    -    if (!this._store.transform) {
    -      alert('Transform is not supported with this backend: ' + this.get('backend'));
    -      return;
    -    }
    -    this.trigger('recline:flash', {message: "Updating all visible docs. This could take a while...", persist: true, loader: true});
    -    this._store.transform(editFunc).done(function() {

    reload data as records have changed

          self.query();
    -      self.trigger('recline:flash', {message: "Records updated successfully"});
    -    });
    +    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.

    @@ -137,7 +130,7 @@

    Resulting RecordList are used to reset this.records and are also returned.

      query: function(queryObj) {
         var self = this;
    -    var dfd = $.Deferred();
    +    var dfd = new Deferred();
         this.trigger('query:start');
     
         if (queryObj) {
    @@ -151,9 +144,9 @@
             self.trigger('query:done');
             dfd.resolve(self.records);
           })
    -      .fail(function(arguments) {
    -        self.trigger('query:fail', arguments);
    -        dfd.reject(arguments);
    +      .fail(function(args) {
    +        self.trigger('query:fail', args);
    +        dfd.reject(args);
           });
         return dfd.promise();
       },
    @@ -198,7 +191,7 @@
         this.fields.each(function(field) {
           query.addFacet(field.id);
         });
    -    var dfd = $.Deferred();
    +    var dfd = new Deferred();
         this._store.query(query.toJSON(), this.toJSON()).done(function(queryResult) {
           if (queryResult.facets) {
             _.each(queryResult.facets, function(facetResult, facetId) {
    @@ -241,16 +234,23 @@
       },

    getFieldValue

    For the provided Field get the corresponding rendered computed data value -for this record.

      getFieldValue: function(field) {
    +for this record.

    + +

    NB: if field is undefined a default '' value will be returned

      getFieldValue: function(field) {
         val = this.getFieldValueUnrendered(field);
    -    if (field.renderer) {
    +    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.

      getFieldValueUnrendered: function(field) {
    +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);
    @@ -350,7 +350,7 @@
     here that are not actually strings

            if (val && typeof val === 'string') {
               val = val.replace(/(https?:\/\/[^ ]+)/g, '<a href="$1">$1</a>');
             }
    -        return val
    +        return val;
           }
         }
       }
    @@ -398,7 +398,7 @@
     

    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 = _.extend(this._filterTemplates[filter.type], ourfilter);
    +      ourfilter = _.defaults(ourfilter, this._filterTemplates[filter.type]);
         }
         var filters = this.get('filters');
         filters.push(ourfilter);
    @@ -464,6 +464,6 @@
       return model.backend.sync(method, model, options);
     };
     
    -}(jQuery, this.recline.Model));
    +}(this.recline.Model));
     
     
    \ No newline at end of file diff --git a/docs/src/view.flot.html b/docs/src/view.flot.html new file mode 100644 index 000000000..1ed374bdb --- /dev/null +++ b/docs/src/view.flot.html @@ -0,0 +1,452 @@ + view.flot.js

    view.flot.js

    /*jshint multistr:true */
    +
    +this.recline = this.recline || {};
    +this.recline.View = this.recline.View || {};
    +
    +(function($, my) {

    Graph view for a Dataset using Flot graphing library.

    + +

    Initialization arguments (in a hash in first parameter):

    + +
      +
    • model: recline.Model.Dataset
    • +
    • state: (optional) configuration hash of form:

      + +

      { + group: {column name for x-axis}, + series: [{column name for series A}, {column name series B}, ... ], + graphType: 'line', + graphOptions: {custom [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"];
    +
    +    this.el = $(this.el);
    +    _.bindAll(this, 'render', 'redraw', '_toolTip', '_xaxisLabel');
    +    this.needToRedraw = false;
    +    this.model.bind('change', this.render);
    +    this.model.fields.bind('reset', this.render);
    +    this.model.fields.bind('add', this.render);
    +    this.model.records.bind('add', this.redraw);
    +    this.model.records.bind('reset', this.redraw);
    +    var stateData = _.extend({
    +        group: null,

    so that at least one series chooser box shows up

            series: [],
    +        graphType: 'lines-and-points'
    +      },
    +      options.state
    +    );
    +    this.state = new recline.Model.ObjectState(stateData);
    +    this.previousTooltipPoint = {x: null, y: null};
    +    this.editor = new my.FlotControls({
    +      model: this.model,
    +      state: this.state.toJSON()
    +    });
    +    this.editor.state.bind('change', function() {
    +      self.state.set(self.editor.state.toJSON());
    +      self.redraw();
    +    });
    +    this.elSidebar = this.editor.el;
    +  },
    +
    +  render: function() {
    +    var self = this;
    +    var tmplData = this.model.toTemplateJSON();
    +    var htmls = Mustache.render(this.template, tmplData);
    +    $(this.el).html(htmls);
    +    this.$graph = this.el.find('.panel.graph');
    +    this.$graph.on("plothover", this._toolTip);
    +    return this;
    +  },
    +
    +  redraw: function() {

    There are issues generating a Flot graph if either: +* The relevant div that graph attaches to his hidden at the moment of creating the plot -- Flot will complain with + Uncaught Invalid dimensions for plot, width = 0, height = 0 +* There is no data for the plot -- either same error or may have issues later with errors like 'non-existent node-value'

        var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]);
    +    if ((!areWeVisible || this.model.records.length === 0)) {
    +      this.needToRedraw = true;
    +      return;
    +    }

    check we have something to plot

        if (this.state.get('group') && this.state.get('series')) {
    +      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 (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;
    +    this.el = $(this.el);
    +    _.bindAll(this, 'render');
    +    this.model.fields.bind('reset', this.render);
    +    this.model.fields.bind('add', this.render);
    +    this.state = new recline.Model.ObjectState(options.state);
    +    this.render();
    +  },
    +
    +  render: function() {
    +    var self = this;
    +    var tmplData = this.model.toTemplateJSON();
    +    var htmls = Mustache.render(this.template, tmplData);
    +    this.el.html(htmls);

    set up editor from state

        if (this.state.get('graphType')) {
    +      this._selectOption('.editor-type', this.state.get('graphType'));
    +    }
    +    if (this.state.get('group')) {
    +      this._selectOption('.editor-group', this.state.get('group'));
    +    }

    ensure at least one series box shows up

        var tmpSeries = [""];
    +    if (this.state.get('series').length > 0) {
    +      tmpSeries = this.state.get('series');
    +    }
    +    _.each(tmpSeries, function(series, idx) {
    +      self.addSeries(idx);
    +      self._selectOption('.editor-series.js-series-' + idx, series);
    +    });
    +    return this;
    +  },

    Private: Helper function to select an option from a select list

      _selectOption: function(id,value){
    +    var options = this.el.find(id + ' select > option');
    +    if (options) {
    +      options.each(function(opt){
    +        if (this.value == value) {
    +          $(this).attr('selected','selected');
    +          return false;
    +        }
    +      });
    +    }
    +  },
    +
    +  onEditorSubmit: function(e) {
    +    var select = this.el.find('.editor-group select');
    +    var $editor = this;
    +    var $series  = this.el.find('.editor-series select');
    +    var series = $series.map(function () {
    +      return $(this).val();
    +    });
    +    var updatedState = {
    +      series: $.makeArray(series),
    +      group: this.el.find('.editor-group select').val(),
    +      graphType: this.el.find('.editor-type select').val()
    +    };
    +    this.state.set(updatedState);
    +  },

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

    + +

    @param [int] idx index of this series in the list of series

    + +

    Returns itself.

      addSeries: function (idx) {
    +    var data = _.extend({
    +      seriesIndex: idx,
    +      seriesName: String.fromCharCode(idx + 64 + 1)
    +    }, this.model.toTemplateJSON());
    +
    +    var htmls = Mustache.render(this.templateSeriesEditor, data);
    +    this.el.find('.editor-series-group').append(htmls);
    +    return this;
    +  },
    +
    +  _onAddSeries: function(e) {
    +    e.preventDefault();
    +    this.addSeries(this.state.get('series').length);
    +  },

    Public: Removes a series list item from the editor.

    + +

    Also updates the labels of the remaining series elements.

      removeSeries: function (e) {
    +    e.preventDefault();
    +    var $el = $(e.target);
    +    $el.parent().parent().remove();
    +    this.onEditorSubmit();
    +  }
    +});
    +
    +})(jQuery, recline.View);
    +
    +
    \ No newline at end of file diff --git a/docs/src/view.graph.html b/docs/src/view.graph.html index fd87f132e..4088506ae 100644 --- a/docs/src/view.graph.html +++ b/docs/src/view.graph.html @@ -1,403 +1,6 @@ - view.graph.js

    view.graph.js

    /*jshint multistr:true */
    -
    -this.recline = this.recline || {};
    +      view.graph.js           

    view.graph.js

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

    Graph view for a Dataset using Flot graphing library.

    - -

    Initialization arguments (in a hash in first parameter):

    - -
      -
    • model: recline.Model.Dataset
    • -
    • state: (optional) configuration hash of form:

      - -

      { - group: {column name for x-axis}, - series: [{column name for series A}, {column name series B}, ... ], - graphType: 'line' - }

    • -
    - -

    NB: should not provide an el argument to the view but must let the view -generate the element itself (you can then append view.el to the DOM.

    my.Graph = Backbone.View.extend({
    -  template: ' \
    -    <div class="recline-graph"> \
    -      <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"];
    -
    -    this.el = $(this.el);
    -    _.bindAll(this, 'render', 'redraw');
    -    this.needToRedraw = false;
    -    this.model.bind('change', this.render);
    -    this.model.fields.bind('reset', this.render);
    -    this.model.fields.bind('add', this.render);
    -    this.model.records.bind('add', this.redraw);
    -    this.model.records.bind('reset', this.redraw);
    -    var stateData = _.extend({
    -        group: null,

    so that at least one series chooser box shows up

            series: [],
    -        graphType: 'lines-and-points'
    -      },
    -      options.state
    -    );
    -    this.state = new recline.Model.ObjectState(stateData);
    -    this.editor = new my.GraphControls({
    -      model: this.model,
    -      state: this.state.toJSON()
    -    });
    -    this.editor.state.bind('change', function() {
    -      self.state.set(self.editor.state.toJSON());
    -      self.redraw();
    -    });
    -    this.elSidebar = this.editor.el;
    -  },
    -
    -  render: function() {
    -    var self = this;
    -    var tmplData = this.model.toTemplateJSON();
    -    var htmls = Mustache.render(this.template, tmplData);
    -    $(this.el).html(htmls);
    -    this.$graph = this.el.find('.panel.graph');
    -    return this;
    -  },
    -
    -  redraw: function() {

    There appear to be issues generating a Flot graph if either:

      -
    • The relevant div that graph attaches to his hidden at the moment of creating the plot -- Flot will complain with

      - -

      Uncaught Invalid dimensions for plot, width = 0, height = 0

    • -
    • There is no data for the plot -- either same error or may have issues later with errors like 'non-existent node-value'
    • -
        var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]);
    -    if ((!areWeVisible || this.model.records.length === 0)) {
    -      this.needToRedraw = true;
    -      return;
    -    }

    check we have something to plot

        if (this.state.get('group') && this.state.get('series')) {

    faff around with width because flot draws axes outside of the element width which means graph can get push down as it hits element next to it

          this.$graph.width(this.el.width() - 20);
    -      var series = this.createSeries();
    -      var options = this.getGraphOptions(this.state.attributes.graphType);
    -      this.plot = Flotr.draw(this.$graph.get(0), series, options);
    -    }
    -  },
    -
    -  show: function() {

    because we cannot redraw when hidden we may need to when becoming visible

        if (this.needToRedraw) {
    -      this.redraw();
    -    }
    -  },

    getGraphOptions

    - -

    Get options for Flot Graph

    - -

    needs to be function as can depend on state

    - -

    @param typeId graphType id (lines, lines-and-points etc)

      getGraphOptions: function(typeId) { 
    -    var self = this;
    -
    -    var tickFormatter = function (x) {
    -      return getFormattedX(x);
    -    };
    -    

    infoboxes on mouse hover on points/bars etc

        var trackFormatter = function (obj) {
    -      var x = obj.x;
    -      var y = obj.y;

    it's horizontal so we have to flip

          if (self.state.attributes.graphType === 'bars') {
    -        var _tmp = x;
    -        x = y;
    -        y = _tmp;
    -      }
    -      
    -      x = getFormattedX(x);
    -
    -      var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', {
    -        group: self.state.attributes.group,
    -        x: x,
    -        series: obj.series.label,
    -        y: y
    -      });
    -      
    -      return content;
    -    };
    -    
    -    var getFormattedX = function (x) {
    -      var xfield = self.model.fields.get(self.state.attributes.group);

    time series

          var xtype = xfield.get('type');
    -      var isDateTime = (xtype === 'date' || xtype === 'date-time' || xtype  === 'time');
    -
    -      if (self.model.records.models[parseInt(x)]) {
    -        x = self.model.records.models[parseInt(x)].get(self.state.attributes.group);
    -        if (isDateTime) {
    -          x = new Date(x).toLocaleDateString();
    -        }
    -      } else if (isDateTime) {
    -        x = new Date(parseInt(x)).toLocaleDateString();
    -      }
    -      return x;    
    -    }
    -    
    -    var xaxis = {};
    -    xaxis.tickFormatter = tickFormatter;
    -
    -    var yaxis = {};
    -    yaxis.autoscale = true;
    -    yaxis.autoscaleMargin = 0.02;
    -    
    -    var mouse = {};
    -    mouse.track = true;
    -    mouse.relative = true;
    -    mouse.trackFormatter = trackFormatter;
    -    
    -    var legend = {};
    -    legend.position = 'ne';
    -    

    mouse.lineColor is set in createSeries

        var optionsPerGraphType = { 
    -      lines: {
    -        legend: legend,
    -        colors: this.graphColors,
    -        lines: { show: true },
    -        xaxis: xaxis,
    -        yaxis: yaxis,
    -        mouse: mouse
    -      },
    -      points: {
    -        legend: legend,
    -        colors: this.graphColors,
    -        points: { show: true, hitRadius: 5 },
    -        xaxis: xaxis,
    -        yaxis: yaxis,
    -        mouse: mouse,
    -        grid: { hoverable: true, clickable: true }
    -      },
    -      'lines-and-points': {
    -        legend: legend,
    -        colors: this.graphColors,
    -        points: { show: true, hitRadius: 5 },
    -        lines: { show: true },
    -        xaxis: xaxis,
    -        yaxis: yaxis,
    -        mouse: mouse,
    -        grid: { hoverable: true, clickable: true }
    -      },
    -      bars: {
    -        legend: legend,
    -        colors: this.graphColors,
    -        lines: { show: false },
    -        xaxis: yaxis,
    -        yaxis: xaxis,
    -        mouse: { 
    -          track: true,
    -          relative: true,
    -          trackFormatter: trackFormatter,
    -          fillColor: '#FFFFFF',
    -          fillOpacity: 0.3,
    -          position: 'e'
    -        },
    -        bars: {
    -          show: true,
    -          horizontal: true,
    -          shadowSize: 0,
    -          barWidth: 0.8         
    -        }
    -      },
    -      columns: {
    -        legend: legend,
    -        colors: this.graphColors,
    -        lines: { show: false },
    -        xaxis: xaxis,
    -        yaxis: yaxis,
    -        mouse: { 
    -            track: true,
    -            relative: true,
    -            trackFormatter: trackFormatter,
    -            fillColor: '#FFFFFF',
    -            fillOpacity: 0.3,
    -            position: 'n'
    -        },
    -        bars: {
    -            show: true,
    -            horizontal: false,
    -            shadowSize: 0,
    -            barWidth: 0.8         
    -        }
    -      },
    -      grid: { hoverable: true, clickable: true }
    -    };
    -    return optionsPerGraphType[typeId];
    -  },
    -
    -  createSeries: function() {
    -    var self = this;
    -    var series = [];
    -    _.each(this.state.attributes.series, function(field) {
    -      var points = [];
    -      _.each(self.model.records.models, function(doc, index) {
    -        var xfield = self.model.fields.get(self.state.attributes.group);
    -        var x = doc.getFieldValue(xfield);

    time series

            var xtype = xfield.get('type');
    -        var isDateTime = (xtype === 'date' || xtype === 'date-time' || xtype  === 'time');
    -        
    -        if (isDateTime) {

    datetime

              if (self.state.attributes.graphType != 'bars' && self.state.attributes.graphType != 'columns') {

    not bar or column

                x = new Date(x).getTime();
    -          } else {

    bar or column

                x = index;
    -          }
    -        } else if (typeof x === 'string') {

    string

              x = parseFloat(x);
    -          if (isNaN(x)) {
    -            x = index;
    -          }
    -        }
    -
    -        var yfield = self.model.fields.get(field);
    -        var y = doc.getFieldValue(yfield);
    -        

    horizontal bar chart

            if (self.state.attributes.graphType == 'bars') {
    -          points.push([y, x]);
    -        } else {
    -          points.push([x, y]);
    -        }
    -      });
    -      series.push({data: points, label: field, mouse:{lineColor: self.graphColors[series.length]}});
    -    });
    -    return series;
    -  }
    -});
    -
    -my.GraphControls = Backbone.View.extend({
    -  className: "editor",
    -  template: ' \
    -  <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 (x-axis)</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}} (y-axis)</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;
    -    this.el = $(this.el);
    -    _.bindAll(this, 'render');
    -    this.model.fields.bind('reset', this.render);
    -    this.model.fields.bind('add', this.render);
    -    this.state = new recline.Model.ObjectState(options.state);
    -    this.render();
    -  },
    -
    -  render: function() {
    -    var self = this;
    -    var tmplData = this.model.toTemplateJSON();
    -    var htmls = Mustache.render(this.template, tmplData);
    -    this.el.html(htmls);

    set up editor from state

        if (this.state.get('graphType')) {
    -      this._selectOption('.editor-type', this.state.get('graphType'));
    -    }
    -    if (this.state.get('group')) {
    -      this._selectOption('.editor-group', this.state.get('group'));
    -    }

    ensure at least one series box shows up

        var tmpSeries = [""];
    -    if (this.state.get('series').length > 0) {
    -      tmpSeries = this.state.get('series');
    -    }
    -    _.each(tmpSeries, function(series, idx) {
    -      self.addSeries(idx);
    -      self._selectOption('.editor-series.js-series-' + idx, series);
    -    });
    -    return this;
    -  },

    Private: Helper function to select an option from a select list

      _selectOption: function(id,value){
    -    var options = this.el.find(id + ' select > option');
    -    if (options) {
    -      options.each(function(opt){
    -        if (this.value == value) {
    -          $(this).attr('selected','selected');
    -          return false;
    -        }
    -      });
    -    }
    -  },
    -
    -  onEditorSubmit: function(e) {
    -    var select = this.el.find('.editor-group select');
    -    var $editor = this;
    -    var $series  = this.el.find('.editor-series select');
    -    var series = $series.map(function () {
    -      return $(this).val();
    -    });
    -    var updatedState = {
    -      series: $.makeArray(series),
    -      group: this.el.find('.editor-group select').val(),
    -      graphType: this.el.find('.editor-type select').val()
    -    };
    -    this.state.set(updatedState);
    -  },

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

    - -

    @param [int] idx index of this series in the list of series

    - -

    Returns itself.

      addSeries: function (idx) {
    -    var data = _.extend({
    -      seriesIndex: idx,
    -      seriesName: String.fromCharCode(idx + 64 + 1)
    -    }, this.model.toTemplateJSON());
    -
    -    var htmls = Mustache.render(this.templateSeriesEditor, data);
    -    this.el.find('.editor-series-group').append(htmls);
    -    return this;
    -  },
    -
    -  _onAddSeries: function(e) {
    -    e.preventDefault();
    -    this.addSeries(this.state.get('series').length);
    -  },

    Public: Removes a series list item from the editor.

    - -

    Also updates the labels of the remaining series elements.

      removeSeries: function (e) {
    -    e.preventDefault();
    -    var $el = $(e.target);
    -    $el.parent().parent().remove();
    -    this.onEditorSubmit();
    -  }
    -});
    -
    -})(jQuery, recline.View);
    +this.recline.View.Graph = this.recline.View.Flot;
    +this.recline.View.GraphControls = this.recline.View.FlotControls;
     
     
    \ No newline at end of file diff --git a/docs/src/view.grid.html b/docs/src/view.grid.html index 13efa9b74..fc0d31e49 100644 --- a/docs/src/view.grid.html +++ b/docs/src/view.grid.html @@ -1,4 +1,4 @@ - view.grid.js

    view.grid.js

    /*jshint multistr:true */
    +      view.grid.js           

    view.grid.js

    /*jshint multistr:true */
     
     this.recline = this.recline || {};
     this.recline.View = this.recline.View || {};
    @@ -85,8 +85,8 @@ 

    Templating

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

    if columns extend outside viewport then remainder is 0

        var remainder = Math.max(fullWidth - numFields * width,0);
    -    _.each(this.fields, function(field, idx) {

    add the remainder to the first field width so we make up full col

          if (idx == 0) {
    +    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);
    +    _.each(this.fields, 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});
    diff --git a/docs/src/view.map.html b/docs/src/view.map.html
    index d9f49d170..c37709b7f 100644
    --- a/docs/src/view.map.html
    +++ b/docs/src/view.map.html
    @@ -1,4 +1,4 @@
    -      view.map.js           

    view.map.js

    /*jshint multistr:true */
    +      view.map.js           

    view.map.js

    /*jshint multistr:true */
     
     this.recline = this.recline || {};
     this.recline.View = this.recline.View || {};
    @@ -6,9 +6,16 @@
     (function($, my) {

    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 either via a field with -GeoJSON objects or two fields with latitude and -longitude coordinates.

    +information can be provided in 2 ways:

    + +
      +
    1. Via a single field. This field must be either a geo_point or +GeoJSON object
    2. +
    3. Via two fields with latitude and longitude coordinates.
    4. +
    + +

    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:

    @@ -19,6 +26,9 @@ 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 } @@ -103,7 +113,32 @@ } } return html; - },

    END: Customization section

    Public: Adds the necessary elements to the page.

    + },

    Options to use for the Leaflet GeoJSON layer +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;
    @@ -113,7 +148,7 @@
         this.$map = this.el.find('.panel.map');
         this.redraw();
         return this;
    -  },

    Public: Redraws the features on the map according to the action provided

    + },

    Public: Redraws the features on the map according to the action provided

    Actions can be:

    @@ -124,33 +159,39 @@
  • 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()){
    +    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);
    +    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.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;
    +      }

    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')){
    @@ -160,15 +201,10 @@
               this._zoomPending = true;
             }
           }
    -      if (this.state.get('cluster')) {
    -        this.map.addLayer(this.markers);
    -      } else {
    -        this.map.addLayer(this.features);
    -      }
         }
       },
     
    -  show: function() {

    If the div was hidden, Leaflet needs to recalculate some sizes + 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')) {
    @@ -185,7 +221,7 @@
     
       _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

    + },

    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 @@ -201,10 +237,10 @@ _.every(docs, function(doc){ count += 1; var feature = self._getGeometryFromRecord(doc); - if (typeof feature === 'undefined' || feature === null){

    Empty field

            return true;
    +      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 + 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
             };
     
    @@ -226,7 +262,7 @@
           }
           return true;
         });
    -  },

    Private: Remove one or n features from the map

      _remove: function(docs){
    +  },

    Private: Remove one or n features from the map

      _remove: function(docs){
     
         var self = this;
     
    @@ -240,10 +276,10 @@
           }
         });
     
    -  },

    Private: Return a GeoJSON geomtry extracted from the record fields

      _getGeometryFromRecord: function(doc){
    +  },

    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 {
    +      if (typeof(value) === 'string'){

    We may have a GeoJSON string representation

            try {
               value = $.parseJSON(value);
             } catch(e) {}
           }
    @@ -261,16 +297,16 @@
             } else {
               return null;
             }
    -      } else if (value && value.slice) {

    [ lon, lat ]

            return {
    +      } 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 {
    +      } 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'));
    +      }

    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 {
    @@ -280,10 +316,10 @@
           }
         }
         return null;
    -  },

    Private: Check if there is a field with GeoJSON geometries or alternatively, + },

    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()) {
    +

    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),
    @@ -291,7 +327,7 @@
           });
           this.menu.state.set(this.state.toJSON());
         }
    -  },

    Private: Check if a field in the current model exists in the provided + },

    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');
    @@ -302,7 +338,7 @@
           }
         }
         return null;
    -  },

    Private: Zoom to map to current features extent if any, or to the full + },

    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()){
    @@ -310,7 +346,7 @@
         } else {
           this.map.setView([0, 0], 2);
         }
    -  },

    Private: Sets up the Leaflet map control and the features layer.

    + },

    Private: Sets up the Leaflet map control and the features layer.

    The map uses a base layer from MapQuest based on OpenStreetMap.

      _setupMap: function(){
    @@ -322,21 +358,15 @@
         var bg = new L.TileLayer(mapUrl, {maxZoom: 18, attribution: osmAttribution ,subdomains: '1234'});
         this.map.addLayer(bg);
     
    -    this.markers = new L.MarkerClusterGroup(this._clusterOptions);
    -
    -    this.features = new L.GeoJSON(null,{
    -        pointToLayer: function (feature, latlng) {
    -          var marker = new L.marker(latlng);
    -          marker.bindPopup(feature.properties.popupContent);
    -          self.markers.addLayer(marker);
    -          return marker;
    -        }
    -    });
    +    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){
    +  },

    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){
    @@ -409,7 +439,7 @@
           <input type="hidden" class="editor-id" value="map-1" /> \
           </div> \
         </form> \
    -  ',

    Define here events for UI elements

      events: {
    +  ',

    Define here events for UI elements

      events: {
         'click .editor-update-map': 'onEditorSubmit',
         'change .editor-field-type': 'onFieldTypeChange',
         'click #editor-auto-zoom': 'onAutoZoomChange',
    @@ -424,7 +454,7 @@
         this.state = new recline.Model.ObjectState(options.state);
         this.state.bind('change', this.render);
         this.render();
    -  },

    Public: Adds the necessary elements to the page.

    + },

    Public: Adds the necessary elements to the page.

    Also sets up the editor fields and the map if necessary.

      render: function() {
         var self = this;
    @@ -456,7 +486,7 @@
     
       _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

    + },

    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){
    @@ -475,7 +505,7 @@
           });
         }
         return false;
    -  },

    Public: Shows the relevant select lists depending on the location field + },

    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();
    @@ -492,7 +522,7 @@
     
       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){
    +  },

    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){
    diff --git a/docs/src/view.multiview.html b/docs/src/view.multiview.html
    index d1f2f3837..0bba6c7f3 100644
    --- a/docs/src/view.multiview.html
    +++ b/docs/src/view.multiview.html
    @@ -1,4 +1,4 @@
    -      view.multiview.js           

    view.multiview.js

    /*jshint multistr:true */

    Standard JS module setup

    this.recline = this.recline || {};
    +      view.multiview.js           

    view.multiview.js

    /*jshint multistr:true */

    Standard JS module setup

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

    MultiView

    @@ -107,7 +107,7 @@

    Parameters

    <div class="menu-right"> \ <div class="btn-group" data-toggle="buttons-checkbox"> \ {{#sidebarViews}} \ - <a href="#" data-action="{{id}}" class="btn active">{{label}}</a> \ + <a href="#" data-action="{{id}}" class="btn">{{label}}</a> \ {{/sidebarViews}} \ </div> \ </div> \ @@ -156,12 +156,6 @@

    Parameters

    model: this.model, state: this.state.get('view-timeline') }) - }, { - id: 'transform', - label: 'Transform', - view: new my.Transform({ - model: this.model - }) }]; }

    Hashes of sidebar elements

        if(options.sidebarViews) {
           this.sidebarViews = options.sidebarViews;
    @@ -189,6 +183,7 @@ 

    Parameters

    } else { this.updateNav(this.pageViews[0].id); } + this._showHideSidebar(); this.model.bind('query:start', function() { self.notify({loader: true, persist: true}); @@ -255,20 +250,28 @@

    Parameters

    }); this.el.find('.query-editor-here').append(queryEditor.el); + },

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

    show the specific page

        _.each(this.pageViews, function(view, idx) {
    +    $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();
             }
    -        if (view.view.show) {
    -          view.view.show();
    -        }
           } else {
             view.view.el.hide();
             if (view.view.elSidebar) {
    @@ -279,12 +282,22 @@ 

    Parameters

    } } }); + + 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) {
    @@ -292,15 +305,15 @@ 

    Parameters

    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

    + },

    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 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 ? 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__,
    @@ -314,7 +327,7 @@ 

    Parameters

    }, _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.model.queryState.bind('change', 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.model.queryState.bind('change', function() {
           self.state.set({query: self.model.queryState.toJSON()});
         });
         _.each(this.pageViews, function(pageView) {
    @@ -324,7 +337,7 @@ 

    Parameters

    self.state.set(update); pageView.view.state.bind('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});
    +          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');
             });
           }
    @@ -338,7 +351,7 @@ 

    Parameters

    self.notify(flash); }); }); - },

    notify

    + },

    notify

    Create a notification (a div.alert in div.alert-messsages) using provided flash object. Flash attributes (all are optional):

    @@ -378,7 +391,7 @@

    Parameters

    }); }, 1000); } - },

    clearNotifications

    + },

    clearNotifications

    Clear all existing notifications

      clearNotifications: function() {
         var $notifications = $('.recline-data-explorer .alert-messages .alert');
    @@ -386,17 +399,18 @@ 

    Parameters

    $(this).remove(); }); } -});

    MultiView.restore

    +});

    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!)

      if (state.backend === 'memory') {
    -    var datasetInfo = {
    +

    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 {
    -    var datasetInfo = _.extend({
    +    datasetInfo = _.extend({
             url: state.url,
             backend: state.backend
           },
    @@ -409,7 +423,7 @@ 

    Parameters

    state: state }); return explorer; -}

    Miscellaneous Utilities

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

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

    my.parseHashUrl = function(hashUrl) {
    +};

    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 {};
    @@ -419,7 +433,7 @@ 

    Parameters

    query: parsed[2] || '' }; } -};

    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 {};
       }
    @@ -432,13 +446,13 @@ 

    Parameters

    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]);
    +  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) {
    @@ -453,7 +467,7 @@ 

    Parameters

    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;
    +  if (window.location.hash) {

    slice(1) to remove # at start

        return window.location.hash.split('?')[0].slice(1) + queryPart;
       } else {
         return queryPart;
       }
    diff --git a/docs/src/view.slickgrid.html b/docs/src/view.slickgrid.html
    index 481b5571d..08cab3a1d 100644
    --- a/docs/src/view.slickgrid.html
    +++ b/docs/src/view.slickgrid.html
    @@ -1,4 +1,4 @@
    -      view.slickgrid.js           

    view.slickgrid.js

    /*jshint multistr:true */
    +      view.slickgrid.js           

    view.slickgrid.js

    /*jshint multistr:true */
     
     this.recline = this.recline || {};
     this.recline.View = this.recline.View || {};
    @@ -11,7 +11,24 @@
     
     

    Initialize it with a recline.Model.Dataset.

    -

    NB: you need an explicit height on the element for slickgrid to work

    my.SlickGrid = Backbone.View.extend({
    +

    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 = $(this.el);
    @@ -20,14 +37,18 @@
         this.model.records.bind('add', this.render);
         this.model.records.bind('reset', this.render);
         this.model.records.bind('remove', this.render);
    +    this.model.records.bind('change', this.onRecordChanged, this);
     
         var state = _.extend({
             hiddenColumns: [],
             columnsOrder: [],
             columnsSort: {},
             columnsWidth: [],
    +        columnsEditor: [],
    +        options: {},
             fitColumns: false
           }, modelEtc.state
    +
         );
         this.state = new recline.Model.ObjectState(state);
       },
    @@ -35,16 +56,24 @@
       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 = {
    +    var options = _.extend({
           enableCellNavigation: true,
           enableColumnReorder: true,
           explicitInitialization: true,
           syncColumnCellResize: true,
           forceFitColumns: this.state.get('fitColumns')
    -    };

    We need all columns, even the hidden ones, to show on the column picker

        var columns = [];

    custom formatter as default one escapes html + }, 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);
    @@ -53,55 +82,81 @@
           } else {
             return value;
           }
    -    }
    +    };
         _.each(this.model.fields.toJSON(),function(field){
           var column = {
    -        id:field['id'],
    -        name:field['label'],
    -        field:field['id'],
    +        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});
    +      var widthInfo = _.find(self.state.get('columnsWidth'),function(c){return c.column === field.id;});
           if (widthInfo){
    -        column['width'] = widthInfo.width;
    +        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 = columns.filter(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) {
    +    });

    Restrict the visible columns

        var visibleColumns = columns.filter(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 + }

    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){
    +      if (_.indexOf(_.pluck(visibleColumns,'id'),columns[i].id) === -1){
             tempHiddenColumns.push(columns.splice(i,1)[0]);
           }
         }
    -    columns = columns.concat(tempHiddenColumns);
    -
    -    var data = [];
    -
    -    this.model.records.each(function(doc){
    +    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] = doc.getFieldValueUnrendered(field);
    +        row[field.id] = m.getFieldValueUnrendered(field);
           });
    -      data.push(row);
    +      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 models.indexOf(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');
    +    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');
    +      var sortAsc = sortInfo[0].order !== 'desc';
           this.grid.setSortColumn(column, sortAsc);
         }
     
    @@ -130,19 +185,27 @@
             self.state.set({columnsWidth:columnsWidth});
         });
     
    +    this.grid.onCellChange.subscribe(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;
    +    } else {

    Defer rendering until the view is visible

          self.rendered = false;
         }
     
         return this;
      },
     
    -  show: function() {

    If the div is hidden, SlickGrid will calculate wrongly some + 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();
    @@ -181,7 +244,7 @@
           $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)
    +        $(this).fadeOut(options.fadeSpeed);
           });
           $menu.bind('click', updateColumn);
     
    @@ -198,7 +261,7 @@
             $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) {
    +        if (grid.getColumnIndex(columns[i].id) !== null) {
               $input.attr('checked', 'checked');
             }
             $input.appendTo($li);
    @@ -225,10 +288,12 @@
         }
     
         function updateColumn(e) {
    -      if ($(e.target).data('option') == 'autoresize') {
    +      var checkbox;
    +
    +      if ($(e.target).data('option') === 'autoresize') {
             var checked;
             if ($(e.target).is('li')){
    -            var checkbox = $(e.target).find('input').first();
    +            checkbox = $(e.target).find('input').first();
                 checked = !checkbox.is(':checked');
                 checkbox.attr('checked',checked);
             } else {
    @@ -248,7 +313,7 @@
           if (($(e.target).is('li') && !$(e.target).hasClass('divider')) ||
                 $(e.target).is('input')) {
             if ($(e.target).is('li')){
    -            var checkbox = $(e.target).find('input').first();
    +            checkbox = $(e.target).find('input').first();
                 checkbox.attr('checked',!checkbox.is(':checked'));
             }
             var visibleColumns = [];
    @@ -261,7 +326,6 @@
               }
             });
     
    -
             if (!visibleColumns.length) {
               $(e.target).attr('checked', 'checked');
               return;
    @@ -272,7 +336,7 @@
           }
         }
         init();
    -  }

    Slick.Controls.ColumnPicker

      $.extend(true, window, { Slick:{ Controls:{ ColumnPicker:SlickColumnPicker }}});
    +  }

    Slick.Controls.ColumnPicker

      $.extend(true, window, { Slick:{ Controls:{ ColumnPicker:SlickColumnPicker }}});
     })(jQuery);
     
     
    \ No newline at end of file diff --git a/docs/src/view.timeline.html b/docs/src/view.timeline.html index cc25f5843..6f903f8bf 100644 --- a/docs/src/view.timeline.html +++ b/docs/src/view.timeline.html @@ -1,4 +1,4 @@ - view.timeline.js

    view.timeline.js

    /*jshint multistr:true */
    +      view.timeline.js           

    view.timeline.js

    /*jshint multistr:true */
     
     this.recline = this.recline || {};
     this.recline.View = this.recline.View || {};
    @@ -30,7 +30,8 @@
         });
         var stateData = _.extend({
             startField: null,
    -        endField: null
    +        endField: null,
    +        timelineJSOptions: {}
           },
           options.state
         );
    @@ -53,13 +54,9 @@
       },
     
       _initTimeline: function() {
    -    var $timeline = this.el.find(this.elementId);

    set width explicitly o/w timeline goes wider that screen for some reason

        var width = Math.max(this.el.width(), this.el.find('.recline-timeline').width());
    -    if (width) {
    -      $timeline.width(width);
    -    }
    -    var config = {};
    +    var $timeline = this.el.find(this.elementId);
         var data = this._timelineJSON();
    -    this.timeline.init(data, this.elementId, config);
    +    this.timeline.init(data, this.elementId, this.state.get("timelineJSOptions"));
         this._timelineIsInitialized = true
       },
     
    @@ -68,11 +65,11 @@
           var data = this._timelineJSON();
           this.timeline.reload(data);
         }
    -  },

    Convert record to JSON for timeline

    + },

    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) {
    +  },

    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) {
    @@ -103,7 +100,7 @@
           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) {
    +    });

    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!'
    @@ -123,10 +120,7 @@
         out = out.trim() ? moment(out) : null;
         if (out.toDate() == 'Invalid Date') {
           return null;
    -    } else {

    fix for moment weirdness around date parsing and time zones -moment('1914-08-01').toDate() => 1914-08-01 00:00 +01:00 -which in iso format (with 0 time offset) is 31 July 1914 23:00 -meanwhile native new Date('1914-08-01') => 1914-08-01 01:00 +01:00

          out = out.subtract('minutes', out.zone());
    +    } else {
           return out.toDate();
         }
       },
    diff --git a/docs/src/widget.facetviewer.html b/docs/src/widget.facetviewer.html
    index 61ec2ff51..57ef47afc 100644
    --- a/docs/src/widget.facetviewer.html
    +++ b/docs/src/widget.facetviewer.html
    @@ -1,4 +1,4 @@
    -      widget.facetviewer.js           

    widget.facetviewer.js

    /*jshint multistr:true */
    +      widget.facetviewer.js           

    widget.facetviewer.js

    /*jshint multistr:true */
     
     this.recline = this.recline || {};
     this.recline.View = this.recline.View || {};
    diff --git a/docs/src/widget.fields.html b/docs/src/widget.fields.html
    index 5d2b2cfd1..c98b3c4ba 100644
    --- a/docs/src/widget.fields.html
    +++ b/docs/src/widget.fields.html
    @@ -1,4 +1,4 @@
    -      widget.fields.js           

    widget.fields.js

    /*jshint multistr:true */

    Field Info

    + widget.fields.js

    widget.fields.js

    /*jshint multistr:true */

    Field Info

    For each field

    @@ -28,7 +28,7 @@ </small> \ </h4> \ </div> \ - <div id="collapse{{id}}" class="accordion-body collapse in"> \ + <div id="collapse{{id}}" class="accordion-body collapse"> \ <div class="accordion-inner"> \ {{#facets}} \ <div class="facet-summary" data-facet="{{id}}"> \ @@ -47,9 +47,6 @@ </div> \ ', - events: { - 'click .js-show-hide': 'onShowHide' - }, initialize: function(model) { var self = this; this.el = $(this.el); @@ -62,6 +59,7 @@ });

    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() {
    @@ -76,21 +74,6 @@
         });
         var templated = Mustache.render(this.template, tmplData);
         this.el.html(templated);
    -    this.el.find('.collapse').collapse('hide');
    -  },
    -  onShowHide: function(e) {
    -    e.preventDefault();
    -    var $target  = $(e.target);

    weird collapse class seems to have been removed (can watch this happen -if you watch dom) but could not work why. Absence of collapse then meant -we could not toggle. -This seems to fix the problem.

        this.el.find('.accordion-body').addClass('collapse');;
    -    if ($target.text() === '+') {
    -      this.el.find('.collapse').collapse('show');
    -      $target.text('-');
    -    } else {
    -      this.el.find('.collapse').collapse('hide');
    -      $target.text('+');
    -    }
       }
     });
     
    diff --git a/docs/src/widget.filtereditor.html b/docs/src/widget.filtereditor.html
    index 128e953e9..4d0079b34 100644
    --- a/docs/src/widget.filtereditor.html
    +++ b/docs/src/widget.filtereditor.html
    @@ -1,4 +1,4 @@
    -      widget.filtereditor.js           

    widget.filtereditor.js

    /*jshint multistr:true */
    +      widget.filtereditor.js           

    widget.filtereditor.js

    /*jshint multistr:true */
     
     this.recline = this.recline || {};
     this.recline.View = this.recline.View || {};
    @@ -120,7 +120,7 @@
         $target.hide();
         var filterType = $target.find('select.filterType').val();
         var field      = $target.find('select.fields').val();
    -    this.model.queryState.addFilter({type: filterType, field: field});

    trigger render explicitly as queryState change will not be triggered (as blank value for filter)

        this.render();
    +    this.model.queryState.addFilter({type: filterType, field: field});
       },
       onRemoveFilter: function(e) {
         e.preventDefault();
    @@ -137,7 +137,7 @@
           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'));
    +      var filterIndex = parseInt($input.attr('data-filter-id'), 10);
           var name        = $input.attr('name');
           var value       = $input.val();
     
    @@ -158,7 +158,7 @@
               break;
           }
         });
    -    self.model.queryState.set({filters: filters});
    +    self.model.queryState.set({filters: filters, from: 0});
         self.model.queryState.trigger('change');
       }
     });
    diff --git a/docs/src/widget.pager.html b/docs/src/widget.pager.html
    index 78a0925d8..5956b87e4 100644
    --- a/docs/src/widget.pager.html
    +++ b/docs/src/widget.pager.html
    @@ -1,4 +1,4 @@
    -      widget.pager.js           

    widget.pager.js

    /*jshint multistr:true */
    +      widget.pager.js           

    widget.pager.js

    /*jshint multistr:true */
     
     this.recline = this.recline || {};
     this.recline.View = this.recline.View || {};
    @@ -32,6 +32,8 @@
         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) {
    @@ -43,6 +45,7 @@
         } else {
           newFrom = this.model.get('from') + this.model.get('size');
         }
    +    newFrom = Math.max(newFrom, 0);
         this.model.set({from: newFrom});
       },
       render: function() {
    diff --git a/docs/src/widget.queryeditor.html b/docs/src/widget.queryeditor.html
    index 38f6e2f38..0684637d7 100644
    --- a/docs/src/widget.queryeditor.html
    +++ b/docs/src/widget.queryeditor.html
    @@ -1,4 +1,4 @@
    -      widget.queryeditor.js