diff --git a/dist/recline.css b/dist/recline.css index cc0c58e0..6a7f23d7 100644 --- a/dist/recline.css +++ b/dist/recline.css @@ -17,7 +17,24 @@ margin: auto; } -/********************************************************** +.flotr-mouse-value { + background-color: #FEE !important; + color: #000000 !important; + opacity: 0.8 !important; + border: 1px solid #fdd !important; +} + +.flotr-legend { + border: none !important; +} + +.flotr-legend-bg { + display: none; +} + +.flotr-legend-color-box { + padding: 5px; +}/********************************************************** * (Data) Grid *********************************************************/ diff --git a/dist/recline.dataset.js b/dist/recline.dataset.js index a89228a4..6922abe2 100644 --- a/dist/recline.dataset.js +++ b/dist/recline.dataset.js @@ -495,10 +495,17 @@ my.Query = Backbone.Model.extend({ _filterTemplates: { term: { type: 'term', + // TODO do we need this attribute here? field: '', term: '' }, + range: { + type: 'range', + start: '', + stop: '' + }, geo_distance: { + type: 'geo_distance', distance: 10, unit: 'km', point: { @@ -516,7 +523,8 @@ my.Query = Backbone.Model.extend({ // crude deep copy var ourfilter = JSON.parse(JSON.stringify(filter)); // not full specified so use template and over-write - if (_.keys(filter).length <= 2) { + // 3 as for 'type', 'field' and 'fieldType' + if (_.keys(filter).length <= 3) { ourfilter = _.extend(this._filterTemplates[filter.type], ourfilter); } var filters = this.get('filters'); @@ -670,8 +678,10 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {}; var numRows = queryObj.size || this.data.length; var start = queryObj.from || 0; var results = this.data; + results = this._applyFilters(results, queryObj); results = this._applyFreeTextQuery(results, queryObj); + // not complete sorting! _.each(queryObj.sort, function(sortObj) { var fieldName = _.keys(sortObj)[0]; @@ -695,15 +705,51 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {}; // in place filtering this._applyFilters = function(results, queryObj) { - _.each(queryObj.filters, function(filter) { - // if a term filter ... - if (filter.type === 'term') { - results = _.filter(results, function(doc) { - return (doc[filter.field] == filter.term); - }); - } + var filters = queryObj.filters; + // register filters + var filterFunctions = { + term : term, + range : range, + geo_distance : geo_distance + }; + var dataParsers = { + number : function (e) { return parseFloat(e, 10); }, + string : function (e) { return e.toString() }, + date : function (e) { return new Date(e).valueOf() } + }; + + // 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); }); - return results; + + // filters definitions + + function term(record, filter) { + var parse = dataParsers[filter.fieldType]; + var value = parse(record[filter.field]); + var term = parse(filter.term); + + return (value === term); + } + + function range(record, filter) { + var parse = dataParsers[filter.fieldType]; + var value = parse(record[filter.field]); + var start = parse(filter.start); + var stop = parse(filter.stop); + + return (value >= start && value <= stop); + } + + function geo_distance() { + // TODO code here + } }; // we OR across fields but AND across terms in query string diff --git a/dist/recline.js b/dist/recline.js index 72b0f388..fc4f7b22 100644 --- a/dist/recline.js +++ b/dist/recline.js @@ -179,7 +179,7 @@ this.recline.Backend.CSV = this.recline.Backend.CSV || {}; if (field === null) { // If field is null set to empty string field = ''; - } else if (typeof field === "string") { + } else if (typeof field === "string" && rxNeedsQuoting.test(field)) { // Convert string to delimited string field = delimiter + field + delimiter; } else if (typeof field === "number") { @@ -212,25 +212,6 @@ this.recline.Backend.CSV = this.recline.Backend.CSV || {}; return out; }; - var rxHasComma = /^\d+$/, - rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/, - // If a string has leading or trailing space, - // contains a comma double quote or a newline - // it needs to be quoted in CSV output - rxNeedsQuoting = /^\s|\s$|,|"|\n/, - trim = (function () { - // Fx 3.1 has a native trim function, it's about 10x faster, use it if it exists - if (String.prototype.trim) { - return function (s) { - return s.trim(); - }; - } else { - return function (s) { - return s.replace(/^\s*/, '').replace(/\s*$/, ''); - }; - } - }()); - var rxIsInt = /^\d+$/, rxIsFloat = /^\d*\.\d+$|^\d+\.\d*$/, // If a string has leading or trailing space, @@ -631,19 +612,43 @@ this.recline.Backend.GDocs = this.recline.Backend.GDocs || {}; // * fields: array of Field objects // * records: array of objects for each row my.fetch = function(dataset) { - var dfd = $.Deferred(); - var url = my.getSpreadsheetAPIUrl(dataset.url); - $.getJSON(url, function(d) { - result = my.parseData(d); - var fields = _.map(result.fields, function(fieldId) { - return {id: fieldId}; + var dfd = $.Deferred(); + var urls = my.getGDocsAPIUrls(dataset.url); + + // TODO cover it with tests + // get the spreadsheet title + (function () { + var titleDfd = $.Deferred(); + + $.getJSON(urls.spreadsheet, function (d) { + titleDfd.resolve({ + spreadsheetTitle: d.feed.title.$t + }); }); - dfd.resolve({ - records: result.records, - fields: fields, - useMemoryStore: true + + return titleDfd.promise(); + }()).then(function (response) { + + // get the actual worksheet data + $.getJSON(urls.worksheet, function(d) { + var result = my.parseData(d); + var fields = _.map(result.fields, function(fieldId) { + return {id: fieldId}; + }); + + dfd.resolve({ + metadata: { + title: response.spreadsheetTitle +" :: "+ result.worksheetTitle, + spreadsheetTitle: response.spreadsheetTitle, + worksheetTitle : result.worksheetTitle + }, + records : result.records, + fields : fields, + useMemoryStore: true + }); }); }); + return dfd.promise(); }; @@ -657,71 +662,86 @@ this.recline.Backend.GDocs = this.recline.Backend.GDocs || {}; // :return: tabular data object (hash with keys: field and data). // // Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows. - my.parseData = function(gdocsSpreadsheet) { - var options = {}; - if (arguments.length > 1) { - options = arguments[1]; - } + my.parseData = function(gdocsSpreadsheet, options) { + var options = options || {}; + var colTypes = options.colTypes || {}; var results = { - fields: [], + fields : [], records: [] }; - // default is no special info on type of columns - var colTypes = {}; - if (options.colTypes) { - colTypes = options.colTypes; - } - if (gdocsSpreadsheet.feed.entry.length > 0) { - for (var k in gdocsSpreadsheet.feed.entry[0]) { - if (k.substr(0, 3) == 'gsx') { - var col = k.substr(4); - results.fields.push(col); - } + var entries = gdocsSpreadsheet.feed.entry || []; + var key; + var colName; + // percentage values (e.g. 23.3%) + var rep = /^([\d\.\-]+)\%$/; + + for(key in entries[0]) { + // it's barely possible it has inherited keys starting with 'gsx$' + if(/^gsx/.test(key)) { + colName = key.substr(4); + results.fields.push(colName); } } // converts non numberical values that should be numerical (22.3%[string] -> 0.223[float]) - var rep = /^([\d\.\-]+)\%$/; - results.records = _.map(gdocsSpreadsheet.feed.entry, function(entry) { + results.records = _.map(entries, function(entry) { var row = {}; + _.each(results.fields, function(col) { var _keyname = 'gsx$' + col; - var value = entry[_keyname]['$t']; + var value = entry[_keyname].$t; + var num; + + // TODO cover this part of code with test + // TODO use the regexp only once // if labelled as % and value contains %, convert - if (colTypes[col] == 'percent') { - if (rep.test(value)) { - var value2 = rep.exec(value); - var value3 = parseFloat(value2); - value = value3 / 100; - } + if(colTypes[col] === 'percent' && rep.test(value)) { + num = rep.exec(value)[1]; + value = parseFloat(num) / 100; } + row[col] = value; }); + return row; }); + + results.worksheetTitle = gdocsSpreadsheet.feed.title.$t; return results; }; // Convenience function to get GDocs JSON API Url from standard URL - my.getSpreadsheetAPIUrl = function(url) { - if (url.indexOf('feeds/list') != -1) { - return url; - } else { - // https://docs.google.com/spreadsheet/ccc?key=XXXX#gid=0 - var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+).*/; - var matches = url.match(regex); - if (matches) { - var key = matches[1]; - var worksheet = 1; - var out = 'https://spreadsheets.google.com/feeds/list/' + key + '/' + worksheet + '/public/values?alt=json'; - return out; - } else { - alert('Failed to extract gdocs key from ' + url); - } + 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; + urls = { + worksheet : 'https://spreadsheets.google.com/feeds/list/'+ key +'/'+ worksheet +'/public/values?alt=json', + spreadsheet: 'https://spreadsheets.google.com/feeds/worksheets/'+ key +'/public/basic?alt=json' + } } + else { + // we assume that it's one of the feeds urls + key = url.split('/')[5]; + // by default then, take first worksheet + worksheet = 1; + urls = { + worksheet : 'https://spreadsheets.google.com/feeds/list/'+ key +'/'+ worksheet +'/public/values?alt=json', + spreadsheet: 'https://spreadsheets.google.com/feeds/worksheets/'+ key +'/public/basic?alt=json' + } + } + + return urls; }; }(jQuery, this.recline.Backend.GDocs)); - this.recline = this.recline || {}; this.recline.Backend = this.recline.Backend || {}; this.recline.Backend.Memory = this.recline.Backend.Memory || {}; @@ -787,8 +807,10 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {}; var numRows = queryObj.size || this.data.length; var start = queryObj.from || 0; var results = this.data; + results = this._applyFilters(results, queryObj); results = this._applyFreeTextQuery(results, queryObj); + // not complete sorting! _.each(queryObj.sort, function(sortObj) { var fieldName = _.keys(sortObj)[0]; @@ -812,15 +834,51 @@ this.recline.Backend.Memory = this.recline.Backend.Memory || {}; // in place filtering this._applyFilters = function(results, queryObj) { - _.each(queryObj.filters, function(filter) { - // if a term filter ... - if (filter.type === 'term') { - results = _.filter(results, function(doc) { - return (doc[filter.field] == filter.term); - }); - } + var filters = queryObj.filters; + // register filters + var filterFunctions = { + term : term, + range : range, + geo_distance : geo_distance + }; + var dataParsers = { + number : function (e) { return parseFloat(e, 10); }, + string : function (e) { return e.toString() }, + date : function (e) { return new Date(e).valueOf() } + }; + + // 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); }); - return results; + + // filters definitions + + function term(record, filter) { + var parse = dataParsers[filter.fieldType]; + var value = parse(record[filter.field]); + var term = parse(filter.term); + + return (value === term); + } + + function range(record, filter) { + var parse = dataParsers[filter.fieldType]; + var value = parse(record[filter.field]); + var start = parse(filter.start); + var stop = parse(filter.stop); + + return (value >= start && value <= stop); + } + + function geo_distance() { + // TODO code here + } }; // we OR across fields but AND across terms in query string @@ -1466,10 +1524,17 @@ my.Query = Backbone.Model.extend({ _filterTemplates: { term: { type: 'term', + // TODO do we need this attribute here? field: '', term: '' }, + range: { + type: 'range', + start: '', + stop: '' + }, geo_distance: { + type: 'geo_distance', distance: 10, unit: 'km', point: { @@ -1487,7 +1552,8 @@ my.Query = Backbone.Model.extend({ // crude deep copy var ourfilter = JSON.parse(JSON.stringify(filter)); // not full specified so use template and over-write - if (_.keys(filter).length <= 2) { + // 3 as for 'type', 'field' and 'fieldType' + if (_.keys(filter).length <= 3) { ourfilter = _.extend(this._filterTemplates[filter.type], ourfilter); } var filters = this.get('filters'); @@ -1599,22 +1665,22 @@ this.recline.View = this.recline.View || {}; // 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({ - tagName: "div", - className: "recline-graph", - template: ' \ -
\ -
\ -

Hey there!

\ -

There\'s no graph here yet because we don\'t know what fields you\'d like to see plotted.

\ -

Please tell us by using the menu on the right and a graph will automatically appear.

\ +
\ +
\ +
\ +

Hey there!

\ +

There\'s no graph here yet because we don\'t know what fields you\'d like to see plotted.

\ +

Please tell us by using the menu on the right and a graph will automatically appear.

\ +
\ +
\
\ -
\ -
\ ', initialize: function(options) { var self = this; + this.graphColors = ["#edc240", "#afd8f8", "#cb4b4b", "#4da74d", "#9440ed"]; + this.el = $(this.el); _.bindAll(this, 'render', 'redraw'); this.needToRedraw = false; @@ -1623,12 +1689,6 @@ my.Graph = Backbone.View.extend({ this.model.fields.bind('add', this.render); this.model.records.bind('add', this.redraw); this.model.records.bind('reset', this.redraw); - // because we cannot redraw when hidden we may need when becoming visible - this.bind('view:show', function() { - if (this.needToRedraw) { - self.redraw(); - } - }); var stateData = _.extend({ group: null, // so that at least one series chooser box shows up @@ -1647,7 +1707,6 @@ my.Graph = Backbone.View.extend({ self.redraw(); }); this.elSidebar = this.editor.el; - this.render(); }, render: function() { @@ -1671,14 +1730,21 @@ my.Graph = Backbone.View.extend({ 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 = $.plot(this.$graph, series, options); - this.setupTooltips(); + 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(); } }, @@ -1691,138 +1757,142 @@ my.Graph = Backbone.View.extend({ // @param typeId graphType id (lines, lines-and-points etc) getGraphOptions: function(typeId) { var self = this; - // special tickformatter to show labels rather than numbers - // TODO: we should really use tickFormatter and 1 interval ticks if (and - // only if) x-axis values are non-numeric - // However, that is non-trivial to work out from a dataset (datasets may - // have no field type info). Thus at present we only do this for bars. - var tickFormatter = function (val) { - if (self.model.records.models[val]) { - var out = self.model.records.models[val].get(self.state.attributes.group); - // if the value was in fact a number we want that not the - if (typeof(out) == 'number') { - return val; - } else { - return out; - } - } - return val; + + var tickFormatter = function (x) { + return getFormattedX(x); }; - - var xaxis = {}; - // check for time series on x-axis - if (this.model.fields.get(this.state.get('group')).get('type') === 'date') { - xaxis.mode = 'time'; - xaxis.timeformat = '%y-%b'; - } - var optionsPerGraphType = { - lines: { - series: { - lines: { show: true } - }, - xaxis: xaxis - }, - points: { - series: { - points: { show: true } - }, - xaxis: xaxis, - grid: { hoverable: true, clickable: true } - }, - 'lines-and-points': { - series: { - points: { show: true }, - lines: { show: true } - }, - xaxis: xaxis, - grid: { hoverable: true, clickable: true } - }, - bars: { - series: { - lines: {show: false}, - bars: { - show: true, - barWidth: 1, - align: "center", - fill: true, - horizontal: true - } - }, - grid: { hoverable: true, clickable: true }, - yaxis: { - tickSize: 1, - tickLength: 1, - tickFormatter: tickFormatter, - min: -0.5, - max: self.model.records.length - 0.5 - } - } - }; - return optionsPerGraphType[typeId]; - }, - - setupTooltips: function() { - var self = this; - function showTooltip(x, y, contents) { - $('
' + contents + '
').css( { - position: 'absolute', - display: 'none', - top: y + 5, - left: x + 5, - border: '1px solid #fdd', - padding: '2px', - 'background-color': '#fee', - opacity: 0.80 - }).appendTo("body").fadeIn(200); - } - - var previousPoint = null; - this.$graph.bind("plothover", function (event, pos, item) { - if (item) { - if (previousPoint != item.datapoint) { - previousPoint = item.datapoint; - - $("#flot-tooltip").remove(); - var x = item.datapoint[0]; - var y = item.datapoint[1]; + + 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; } - // convert back from 'index' value on x-axis (e.g. in cases where non-number values) - if (self.model.records.models[x]) { - x = self.model.records.models[x].get(self.state.attributes.group); - } else { - x = x.toFixed(2); - } - y = y.toFixed(2); - - // is it time series - var xfield = self.model.fields.get(self.state.attributes.group); - var isDateTime = xfield.get('type') === 'date'; - if (isDateTime) { - x = new Date(parseInt(x)).toLocaleDateString(); - } + x = getFormattedX(x); + var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', { group: self.state.attributes.group, x: x, - series: item.series.label, + series: obj.series.label, y: y }); - showTooltip(item.pageX, item.pageY, content); + + return content; + }; + + var getFormattedX = function (x) { + var xfield = self.model.fields.get(self.state.attributes.group); + + // time series + var isDateTime = xfield.get('type') === 'date'; + + 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(); } - else { - $("#flot-tooltip").remove(); - previousPoint = null; - } - }); + 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 () { + createSeries: function() { var self = this; var series = []; _.each(this.state.attributes.series, function(field) { @@ -1830,19 +1900,30 @@ my.Graph = Backbone.View.extend({ _.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 isDateTime = xfield.get('type') === 'date'; + if (isDateTime) { - x = moment(x).toDate(); - } - var yfield = self.model.fields.get(field); - var y = doc.getFieldValue(yfield); - if (typeof x === 'string') { + // 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]); @@ -1850,7 +1931,7 @@ my.Graph = Backbone.View.extend({ points.push([x, y]); } }); - series.push({data: points, label: field}); + series.push({data: points, label: field, mouse:{lineColor: self.graphColors[series.length]}}); }); return series; } @@ -1869,6 +1950,7 @@ my.GraphControls = Backbone.View.extend({ \ \ \ + \ \ \ \ @@ -2303,11 +2385,10 @@ this.recline.View = this.recline.View || {}; // } // my.Map = Backbone.View.extend({ - tagName: 'div', - className: 'recline-map', - template: ' \ -
\ +
\ +
\ +
\ ', // These are the default (case-insensitive) names of field that are used if found. @@ -2319,6 +2400,18 @@ my.Map = Backbone.View.extend({ initialize: function(options) { var self = this; this.el = $(this.el); + this.visible = true; + this.mapReady = false; + + var stateData = _.extend({ + geomField: null, + lonField: null, + latField: null, + autoZoom: true + }, + options.state + ); + this.state = new recline.Model.ObjectState(stateData); // Listen to changes in the fields this.model.fields.bind('change', function() { @@ -2335,31 +2428,6 @@ my.Map = Backbone.View.extend({ this.model.records.bind('remove', function(doc){self.redraw('remove',doc)}); this.model.records.bind('reset', function(){self.redraw('reset')}); - this.bind('view:show',function(){ - // If the div was hidden, Leaflet needs to recalculate some sizes - // to display properly - if (self.map){ - self.map.invalidateSize(); - if (self._zoomPending && self.state.get('autoZoom')) { - self._zoomToFeatures(); - self._zoomPending = false; - } - } - self.visible = true; - }); - this.bind('view:hide',function(){ - self.visible = false; - }); - - var stateData = _.extend({ - geomField: null, - lonField: null, - latField: null, - autoZoom: true - }, - options.state - ); - this.state = new recline.Model.ObjectState(stateData); this.menu = new my.MapMenu({ model: this.model, state: this.state.toJSON() @@ -2369,10 +2437,6 @@ my.Map = Backbone.View.extend({ self.redraw(); }); this.elSidebar = this.menu.el; - - this.mapReady = false; - this.render(); - this.redraw(); }, // ### Public: Adds the necessary elements to the page. @@ -2384,6 +2448,7 @@ my.Map = Backbone.View.extend({ htmls = Mustache.render(this.template, this.model.toTemplateJSON()); $(this.el).html(htmls); this.$map = this.el.find('.panel.map'); + this.redraw(); return this; }, @@ -2425,6 +2490,23 @@ my.Map = Backbone.View.extend({ } }, + show: function() { + // If the div was hidden, Leaflet needs to recalculate some sizes + // to display properly + if (this.map){ + this.map.invalidateSize(); + if (this._zoomPending && this.state.get('autoZoom')) { + this._zoomToFeatures(); + this._zoomPending = false; + } + } + this.visible = true; + }, + + hide: function() { + this.visible = false; + }, + _geomReady: function() { return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField'))); }, @@ -2883,6 +2965,30 @@ this.recline.View = this.recline.View || {}; // ]; // // +// **sidebarViews**: (optional) the sidebar views (Filters, Fields) for +// MultiView to show. This is an array of view hashes. If not provided +// initialize with (recline.View.)FilterEditor and Fields views (with obvious +// id and labels!). +// +//
+// var sidebarViews = [
+//   {
+//     id: 'filterEditor', // used for routing
+//     label: 'Filters', // used for view switcher
+//     view: new recline.View.FielterEditor({
+//       model: dataset
+//     })
+//   },
+//   {
+//     id: 'fieldsView',
+//     label: 'Fields',
+//     view: new recline.View.Fields({
+//       model: dataset
+//     })
+//   }
+// ];
+// 
+// // **state**: standard state config for this view. This state is slightly // special as it includes config of many of the subviews. // @@ -2940,6 +3046,7 @@ my.MultiView = Backbone.View.extend({ var self = this; this.el = $(this.el); this._setupState(options.state); + // Hash of 'page' views (i.e. those for whole page) keyed by page name if (options.views) { this.pageViews = options.views; @@ -2980,6 +3087,24 @@ my.MultiView = Backbone.View.extend({ }) }]; } + // Hashes of sidebar elements + if(options.sidebarViews) { + this.sidebarViews = options.sidebarViews; + } else { + this.sidebarViews = [{ + id: 'filterEditor', + label: 'Filters', + view: new my.FilterEditor({ + model: this.model + }) + }, { + id: 'fieldsView', + label: 'Fields', + view: new my.Fields({ + model: this.model + }) + }]; + } // these must be called after pageViews are created this.render(); this._bindStateChanges(); @@ -3045,12 +3170,18 @@ my.MultiView = Backbone.View.extend({ // the main views _.each(this.pageViews, function(view, pageName) { + view.view.render(); $dataViewContainer.append(view.view.el); if (view.view.elSidebar) { $dataSidebar.append(view.view.elSidebar); } }); + _.each(this.sidebarViews, function(view) { + this['$'+view.id] = view.view.el; + $dataSidebar.append(view.view.el); + }); + var pager = new recline.View.Pager({ model: this.model.queryState }); @@ -3061,17 +3192,6 @@ my.MultiView = Backbone.View.extend({ }); this.el.find('.query-editor-here').append(queryEditor.el); - var filterEditor = new recline.View.FilterEditor({ - model: this.model - }); - this.$filterEditor = filterEditor.el; - $dataSidebar.append(filterEditor.el); - - var fieldsView = new recline.View.Fields({ - model: this.model - }); - this.$fieldsView = fieldsView.el; - $dataSidebar.append(fieldsView.el); }, updateNav: function(pageName) { @@ -3085,13 +3205,17 @@ my.MultiView = Backbone.View.extend({ if (view.view.elSidebar) { view.view.elSidebar.show(); } - view.view.trigger('view:show'); + if (view.view.show) { + view.view.show(); + } } else { view.view.el.hide(); if (view.view.elSidebar) { view.view.elSidebar.hide(); } - view.view.trigger('view:hide'); + if (view.view.hide) { + view.view.hide(); + } } }); }, @@ -3327,9 +3451,6 @@ this.recline.View = this.recline.View || {}; // // NB: you need an explicit height on the element for slickgrid to work my.SlickGrid = Backbone.View.extend({ - tagName: "div", - className: "recline-slickgrid", - initialize: function(modelEtc) { var self = this; this.el = $(this.el); @@ -3348,23 +3469,6 @@ my.SlickGrid = Backbone.View.extend({ }, modelEtc.state ); this.state = new recline.Model.ObjectState(state); - - this.bind('view: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 (!self.rendered){ - if (!self.grid){ - self.render(); - } - self.grid.init(); - self.rendered = true; - } - self.visible = true; - }); - this.bind('view:hide',function(){ - self.visible = false; - }); - }, events: { @@ -3492,7 +3596,24 @@ my.SlickGrid = Backbone.View.extend({ } return this; - } + }, + + show: function() { + // If the div is hidden, SlickGrid will calculate wrongly some + // sizes so we must render it explicitly when the view is visible + if (!this.rendered){ + if (!this.grid){ + this.render(); + } + this.grid.init(); + this.rendered = true; + } + this.visible = true; + }, + + hide: function() { + this.visible = false; + } }); })(jQuery, recline.View); @@ -3629,8 +3750,6 @@ if (typeof VMM !== 'undefined') { // // Timeline view using http://timeline.verite.co/ my.Timeline = Backbone.View.extend({ - tagName: 'div', - template: ' \
\
\ @@ -3648,12 +3767,6 @@ my.Timeline = Backbone.View.extend({ this.el = $(this.el); this.timeline = new VMM.Timeline(); this._timelineIsInitialized = false; - this.bind('view:show', function() { - // only call _initTimeline once view in DOM as Timeline uses $ internally to look up element - if (self._timelineIsInitialized === false) { - self._initTimeline(); - } - }); this.model.fields.bind('reset', function() { self._setupTemporalField(); }); @@ -3668,7 +3781,12 @@ my.Timeline = Backbone.View.extend({ ); this.state = new recline.Model.ObjectState(stateData); this._setupTemporalField(); - this.render(); + }, + + render: function() { + var tmplData = {}; + var htmls = Mustache.render(this.template, tmplData); + this.el.html(htmls); // can only call _initTimeline once view in DOM as Timeline uses $ // internally to look up element if ($(this.elementId).length > 0) { @@ -3676,10 +3794,11 @@ my.Timeline = Backbone.View.extend({ } }, - render: function() { - var tmplData = {}; - var htmls = Mustache.render(this.template, tmplData); - this.el.html(htmls); + show: function() { + // only call _initTimeline once view in DOM as Timeline uses $ internally to look up element + if (this._timelineIsInitialized === false) { + this._initTimeline(); + } }, _initTimeline: function() { @@ -3831,7 +3950,6 @@ my.Transform = Backbone.View.extend({ initialize: function(options) { this.el = $(this.el); - this.render(); }, render: function() { @@ -4139,6 +4257,7 @@ my.FilterEditor = Backbone.View.extend({ \ \ \ @@ -4172,6 +4291,20 @@ my.FilterEditor = Backbone.View.extend({ \
\ ', + range: ' \ +
\ +
\ + \ + {{field}} {{type}} \ + × \ + \ + \ + \ + \ + \ +
\ +
\ + ', geo_distance: ' \
\
\ @@ -4229,8 +4362,9 @@ my.FilterEditor = Backbone.View.extend({ var $target = $(e.target); $target.hide(); var filterType = $target.find('select.filterType').val(); - var field = $target.find('select.fields').val(); - this.model.queryState.addFilter({type: filterType, field: field}); + var field = $target.find('select.fields').val(); + var fieldType = this.model.fields.find(function (e) { return e.get('id') === field }).get('type'); + this.model.queryState.addFilter({type: filterType, field: field, fieldType: fieldType}); // trigger render explicitly as queryState change will not be triggered (as blank value for filter) this.render(); }, @@ -4247,19 +4381,27 @@ my.FilterEditor = Backbone.View.extend({ var $form = $(e.target); _.each($form.find('input'), function(input) { var $input = $(input); - var filterType = $input.attr('data-filter-type'); - var fieldId = $input.attr('data-filter-field'); + var filterType = $input.attr('data-filter-type'); + var fieldId = $input.attr('data-filter-field'); var filterIndex = parseInt($input.attr('data-filter-id')); - var name = $input.attr('name'); - var value = $input.val(); - if (filterType === 'term') { - filters[filterIndex].term = value; - } else if (filterType === 'geo_distance') { - if (name === 'distance') { - filters[filterIndex].distance = parseFloat(value); - } else { - filters[filterIndex].point[name] = parseFloat(value); - } + var name = $input.attr('name'); + var value = $input.val(); + + switch (filterType) { + case 'term': + filters[filterIndex].term = value; + break; + case 'range': + filters[filterIndex][name] = value; + break; + case 'geo_distance': + if(name === 'distance') { + filters[filterIndex].distance = parseFloat(value); + } + else { + filters[filterIndex].point[name] = parseFloat(value); + } + break; } }); self.model.queryState.set({filters: filters}); diff --git a/docs/src/backend.couchdb.html b/docs/src/backend.couchdb.html index bafff34e..c2602fdf 100644 --- a/docs/src/backend.couchdb.html +++ b/docs/src/backend.couchdb.html @@ -1,4 +1,4 @@ - backend.couchdb.js

backend.couchdb.js

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

backend.couchdb.js

this.recline = this.recline || {};
 this.recline.Backend = this.recline.Backend || {};
 this.recline.Backend.CouchDB = this.recline.Backend.CouchDB || {};
 
diff --git a/docs/src/backend.csv.html b/docs/src/backend.csv.html
index 88c2bd02..c0092c84 100644
--- a/docs/src/backend.csv.html
+++ b/docs/src/backend.csv.html
@@ -1,4 +1,4 @@
-      backend.csv.js           

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 || {};
 
@@ -39,7 +39,7 @@
       });
     } else if (dataset.url) {
       $.get(dataset.url).done(function(data) {
-        var rows = my.parseCSV(dataset.data, dataset);
+        var rows = my.parseCSV(data, dataset);
         dfd.resolve({
           records: rows,
           useMemoryStore: true
@@ -112,14 +112,62 @@ http://www.uselesscode.org/javascript/csv/

row.push(field); out.push(row); + return out; + };

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

+ +

Nulls are converted to empty fields and integers or floats are converted to non-quoted numbers.

+ +

@return The array serialized as a CSV +@type String

+ +

@param {Array} a The array of arrays to convert +@param {Object} options Options for loading CSV including +@param {String} [separator=','] Separator for CSV file +Heavily based on uselesscode's JS CSV parser (MIT Licensed): +http://www.uselesscode.org/javascript/csv/

  my.serializeCSV= function(a, options) {
+    var options = options || {};
+    var separator = options.separator || ',';
+    var delimiter = options.delimiter || '"';
+
+    var cur = '', // The character we are currently processing.
+      field = '', // Buffer for building up the current field
+      row = '',
+      out = '',
+      i,
+      j,
+      processField;
+
+    processField = function (field) {
+      if (field === null) {

If field is null set to empty string

        field = '';
+      } else if (typeof field === "string" && rxNeedsQuoting.test(field)) {

Convert string to delimited string

        field = delimiter + field + delimiter;
+      } else if (typeof field === "number") {

Convert number to string

        field = field.toString(10);
+      }
+
+      return field;
+    };
+
+    for (i = 0; i < a.length; i += 1) {
+      cur = a[i];
+
+      for (j = 0; j < cur.length; j += 1) {
+        field = processField(cur[j]);

If this is EOR append row to output and flush row

        if (j === (cur.length - 1)) {
+          row += field;
+          out += row + "\n";
+          row = '';
+        } else {

Add the current field to the current row

          row += field + separator;
+        }

Flush the field buffer

        field = '';
+      }
+    }
+
     return out;
   };
 
   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();
         };
@@ -131,8 +179,8 @@ it needs to be quoted in CSV output

}()); 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);
     }
   }
 
diff --git a/docs/src/backend.dataproxy.html b/docs/src/backend.dataproxy.html
index 79a83906..f0232bf4 100644
--- a/docs/src/backend.dataproxy.html
+++ b/docs/src/backend.dataproxy.html
@@ -1,4 +1,4 @@
-      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 || {};
 
diff --git a/docs/src/backend.elasticsearch.html b/docs/src/backend.elasticsearch.html
index df2073d0..ea0e1344 100644
--- a/docs/src/backend.elasticsearch.html
+++ b/docs/src/backend.elasticsearch.html
@@ -1,4 +1,4 @@
-      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 || {};
 
@@ -140,7 +140,12 @@ on http://localhost:9200 with index twitter and type tweet it would be:

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();
-    es.mapping().done(function(schema) {

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

      var key = _.keys(schema)[0];
+    es.mapping().done(function(schema) {
+
+      if (!schema){
+        dfd.reject({'message':'Elastic Search did not return a mapping'});
+        return;
+      }

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

      var key = _.keys(schema)[0];
       var fieldData = _.map(schema[key].properties, function(dict, fieldName) {
         dict.id = fieldName;
         return dict;
diff --git a/docs/src/backend.gdocs.html b/docs/src/backend.gdocs.html
index c9ce9ed4..9425ca1d 100644
--- a/docs/src/backend.gdocs.html
+++ b/docs/src/backend.gdocs.html
@@ -1,4 +1,4 @@
-      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 || {};
 
@@ -29,21 +29,39 @@ var dataset = new recline.Model.Dataset({
 
  • fields: array of Field objects
  • records: array of objects for each row
  •   my.fetch = function(dataset) {
    -    var dfd = $.Deferred(); 
    -    var url = my.getSpreadsheetAPIUrl(dataset.url);
    -    $.getJSON(url, function(d) {
    -      result = my.parseData(d);
    -      var fields = _.map(result.fields, function(fieldId) {
    -        return {id: fieldId};
    +    var dfd  = $.Deferred(); 
    +    var urls = my.getGDocsAPIUrls(dataset.url);

    TODO cover it with tests +get the spreadsheet title

        (function () {
    +      var titleDfd = $.Deferred();
    +
    +      $.getJSON(urls.spreadsheet, function (d) {
    +          titleDfd.resolve({
    +              spreadsheetTitle: d.feed.title.$t
    +          });
           });
    -      dfd.resolve({
    -        records: result.records,
    -        fields: fields,
    -        useMemoryStore: true
    +
    +      return titleDfd.promise();
    +    }()).then(function (response) {

    get the actual worksheet data

          $.getJSON(urls.worksheet, function(d) {
    +        var result = my.parseData(d);
    +        var fields = _.map(result.fields, function(fieldId) {
    +          return {id: fieldId};
    +        });
    +
    +        dfd.resolve({
    +          metadata: {
    +              title: response.spreadsheetTitle +" :: "+ result.worksheetTitle,
    +              spreadsheetTitle: response.spreadsheetTitle,
    +              worksheetTitle  : result.worksheetTitle
    +          },
    +          records       : result.records,
    +          fields        : fields,
    +          useMemoryStore: true
    +        });
           });
         });
    +
         return dfd.promise();
    -  };

    parseData

    + };

    parseData

    Parse data from Google Docs API into a reasonable form

    @@ -52,56 +70,64 @@ columnsToUse: list of columns to use (specified by field names) colTypes: dictionary (with column names as keys) specifying types (e.g. range, percent for use in conversion). :return: tabular data object (hash with keys: field and data).

    -

    Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows.

      my.parseData = function(gdocsSpreadsheet) {
    -    var options = {};
    -    if (arguments.length > 1) {
    -      options = arguments[1];
    -    }
    +

    Issues: seems google docs return columns in rows in random order and not even sure whether consistent across rows.

      my.parseData = function(gdocsSpreadsheet, options) {
    +    var options  = options || {};
    +    var colTypes = options.colTypes || {};
         var results = {
    -      fields: [],
    +      fields : [],
           records: []
    -    };

    default is no special info on type of columns

        var colTypes = {};
    -    if (options.colTypes) {
    -      colTypes = options.colTypes;
    -    }
    -    if (gdocsSpreadsheet.feed.entry.length > 0) {
    -      for (var k in gdocsSpreadsheet.feed.entry[0]) {
    -        if (k.substr(0, 3) == 'gsx') {
    -          var col = k.substr(4);
    -          results.fields.push(col);
    -        }
    +    };
    +    var entries = gdocsSpreadsheet.feed.entry || [];
    +    var key;
    +    var colName;

    percentage values (e.g. 23.3%)

        var rep = /^([\d\.\-]+)\%$/;
    +
    +    for(key in entries[0]) {

    it's barely possible it has inherited keys starting with 'gsx$'

          if(/^gsx/.test(key)) {
    +        colName = key.substr(4);
    +        results.fields.push(colName);
           }
    -    }

    converts non numberical values that should be numerical (22.3%[string] -> 0.223[float])

        var rep = /^([\d\.\-]+)\%$/;
    -    results.records = _.map(gdocsSpreadsheet.feed.entry, 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'];

    if labelled as % and value contains %, convert

            if (colTypes[col] == 'percent') {
    -          if (rep.test(value)) {
    -            var value2 = rep.exec(value);
    -            var value3 = parseFloat(value2);
    -            value = value3 / 100;
    -          }
    +        var value = entry[_keyname].$t;
    +        var num;
    + 

    TODO cover this part of code with test +TODO use the regexp only once +if labelled as % and value contains %, convert

            if(colTypes[col] === 'percent' && rep.test(value)) {
    +          num   = rep.exec(value)[1];
    +          value = parseFloat(num) / 100;
             }
    +
             row[col] = value;
           });
    +
           return row;
         });
    +
    +    results.worksheetTitle = gdocsSpreadsheet.feed.title.$t;
         return results;
    -  };

    Convenience function to get GDocs JSON API Url from standard URL

      my.getSpreadsheetAPIUrl = function(url) {
    -    if (url.indexOf('feeds/list') != -1) {
    -      return url;
    -    } else {

    https://docs.google.com/spreadsheet/ccc?key=XXXX#gid=0

          var regex = /.*spreadsheet\/ccc?.*key=([^#?&+]+).*/;
    -      var matches = url.match(regex);
    -      if (matches) {
    -        var key = matches[1];
    -        var worksheet = 1;
    -        var out = 'https://spreadsheets.google.com/feeds/list/' + key + '/' + worksheet + '/public/values?alt=json';
    -        return out;
    -      } else {
    -        alert('Failed to extract gdocs key from ' + url);
    -      }
    +  };

    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;
    +        urls = {
    +          worksheet  : 'https://spreadsheets.google.com/feeds/list/'+ key +'/'+ worksheet +'/public/values?alt=json',
    +          spreadsheet: 'https://spreadsheets.google.com/feeds/worksheets/'+ key +'/public/basic?alt=json'
    +        }
         }
    +    else {

    we assume that it's one of the feeds urls

            key = url.split('/')[5];

    by default then, take first worksheet

            worksheet = 1;
    +        urls = {
    +          worksheet  : 'https://spreadsheets.google.com/feeds/list/'+ key +'/'+ worksheet +'/public/values?alt=json',
    +          spreadsheet: 'https://spreadsheets.google.com/feeds/worksheets/'+ key +'/public/basic?alt=json'
    +        }            
    +    }
    +
    +    return urls;
       };
     }(jQuery, this.recline.Backend.GDocs));
     
    diff --git a/docs/src/backend.memory.html b/docs/src/backend.memory.html
    index 8f515f20..2ea8beb5 100644
    --- a/docs/src/backend.memory.html
    +++ b/docs/src/backend.memory.html
    @@ -1,4 +1,4 @@
    -      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 || {};
     
    @@ -58,6 +58,7 @@ from the data.

    var numRows = queryObj.size || this.data.length; var start = queryObj.from || 0; var results = this.data; + results = this._applyFilters(results, queryObj); results = this._applyFreeTextQuery(results, queryObj);

    not complete sorting!

          _.each(queryObj.sort, function(sortObj) {
             var fieldName = _.keys(sortObj)[0];
    @@ -78,14 +79,38 @@ from the data.

    dfd.resolve(out); return dfd.promise(); };

    in place filtering

        this._applyFilters = function(results, queryObj) {
    -      _.each(queryObj.filters, function(filter) {

    if a term filter ...

            if (filter.type === 'term') {
    -          results = _.filter(results, function(doc) {
    -            return (doc[filter.field] == filter.term);
    -          });
    -        }
    -      });
    -      return results;
    -    };

    we OR across fields but AND across terms in query string

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

    register filters

          var filterFunctions = {
    +        term         : term,
    +        range        : range,
    +        geo_distance : geo_distance
    +      };
    +      var dataParsers = {
    +        number : function (e) { return parseFloat(e, 10); },
    +        string : function (e) { return e.toString() },
    +        date   : function (e) { return new Date(e).valueOf() }
    +      };

    filter records

          return _.filter(results, function (record) {
    +        var passes = _.map(filters, function (filter) {
    +          return filterFunctions[filter.type](record, filter);
    +        });

    return only these records that pass all filters

            return _.all(passes, _.identity);
    +      });

    filters definitions

          function term(record, filter) {
    +        var parse = dataParsers[filter.fieldType];
    +        var value = parse(record[filter.field]);
    +        var term  = parse(filter.term);
    +
    +        return (value === term);
    +      }
    +
    +      function range(record, filter) {
    +        var parse = dataParsers[filter.fieldType];
    +        var value = parse(record[filter.field]);
    +        var start = parse(filter.start);
    +        var stop  = parse(filter.stop);
    +
    +        return (value >= start && value <= stop);
    +      }
    +
    +      function geo_distance() {

    TODO code here

          }
    +    };

    we OR across fields but AND across terms in query string

        this._applyFreeTextQuery = function(results, queryObj) {
           if (queryObj.q) {
             var terms = queryObj.q.split(' ');
             results = _.filter(results, function(rawdoc) {
    @@ -96,10 +121,10 @@ from the data.

    var value = rawdoc[field.id]; if (value !== null) { value = value.toString(); - } else {

    value can be null (apparently in some cases)

                    value = '';
    -              }

    TODO regexes?

                  foundmatch = foundmatch || (value.toLowerCase() === term.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 || (value.toLowerCase() === term.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;
             });
    @@ -112,9 +137,9 @@ if (!matches) return false;

    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];
    @@ -131,7 +156,7 @@ if (!matches) return false;

    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);
           });
    @@ -139,7 +164,7 @@ if (!matches) return false;

    }; this.transform = function(editFunc) { - var toUpdate = costco.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) {
    +      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);
    diff --git a/docs/src/costco.html b/docs/src/costco.html
    deleted file mode 100644
    index 35fbc788..00000000
    --- a/docs/src/costco.html
    +++ /dev/null
    @@ -1,68 +0,0 @@
    -      costco.js           

    costco.js

    adapted from https://github.com/harthur/costco. heather rules

    var costco = function() {
    -  
    -  function evalFunction(funcString) {
    -    try {
    -      eval("var editFunc = " + funcString);
    -    } catch(e) {
    -      return {errorMessage: e+""};
    -    }
    -    return editFunc;
    -  }
    -  
    -  function previewTransform(docs, editFunc, currentColumn) {
    -    var preview = [];
    -    var updated = mapDocs($.extend(true, {}, docs), editFunc);
    -    for (var i = 0; i < updated.docs.length; i++) {      
    -      var before = docs[i]
    -        , after = updated.docs[i]
    -        ;
    -      if (!after) after = {};
    -      if (currentColumn) {
    -        preview.push({before: before[currentColumn], after: after[currentColumn]});      
    -      } else {
    -        preview.push({before: before, after: after});      
    -      }
    -    }
    -    return preview;
    -  }
    -
    -  function mapDocs(docs, editFunc) {
    -    var edited = []
    -      , deleted = []
    -      , failed = []
    -      ;
    -    
    -    var updatedDocs = _.map(docs, function(doc) {
    -      try {
    -        var updated = editFunc(_.clone(doc));
    -      } catch(e) {
    -        failed.push(doc);
    -        return;
    -      }
    -      if(updated === null) {
    -        updated = {_deleted: true};
    -        edited.push(updated);
    -        deleted.push(doc);
    -      }
    -      else if(updated && !_.isEqual(updated, doc)) {
    -        edited.push(updated);
    -      }
    -      return updated;      
    -    });
    -    
    -    return {
    -      updates: edited, 
    -      docs: updatedDocs, 
    -      deletes: deleted, 
    -      failed: failed
    -    };
    -  }
    -  
    -  return {
    -    evalFunction: evalFunction,
    -    previewTransform: previewTransform,
    -    mapDocs: mapDocs
    -  };
    -}();
    -
    -
    \ No newline at end of file diff --git a/docs/src/data.transform.html b/docs/src/data.transform.html new file mode 100644 index 00000000..ef709101 --- /dev/null +++ b/docs/src/data.transform.html @@ -0,0 +1,66 @@ + data.transform.js

    data.transform.js

    this.recline = this.recline || {};
    +this.recline.Data = this.recline.Data || {};
    +
    +(function(my) {

    adapted from https://github.com/harthur/costco. heather rules

    my.Transform = {};
    +
    +my.Transform.evalFunction = function(funcString) {
    +  try {
    +    eval("var editFunc = " + funcString);
    +  } catch(e) {
    +    return {errorMessage: e+""};
    +  }
    +  return editFunc;
    +};
    +
    +my.Transform.previewTransform = function(docs, editFunc, currentColumn) {
    +  var preview = [];
    +  var updated = my.Transform.mapDocs($.extend(true, {}, docs), editFunc);
    +  for (var i = 0; i < updated.docs.length; i++) {      
    +    var before = docs[i]
    +      , after = updated.docs[i]
    +      ;
    +    if (!after) after = {};
    +    if (currentColumn) {
    +      preview.push({before: before[currentColumn], after: after[currentColumn]});      
    +    } else {
    +      preview.push({before: before, after: after});      
    +    }
    +  }
    +  return preview;
    +};
    +
    +my.Transform.mapDocs = function(docs, editFunc) {
    +  var edited = []
    +    , deleted = []
    +    , failed = []
    +    ;
    +  
    +  var updatedDocs = _.map(docs, function(doc) {
    +    try {
    +      var updated = editFunc(_.clone(doc));
    +    } catch(e) {
    +      failed.push(doc);
    +      return;
    +    }
    +    if(updated === null) {
    +      updated = {_deleted: true};
    +      edited.push(updated);
    +      deleted.push(doc);
    +    }
    +    else if(updated && !_.isEqual(updated, doc)) {
    +      edited.push(updated);
    +    }
    +    return updated;      
    +  });
    +  
    +  return {
    +    updates: edited, 
    +    docs: updatedDocs, 
    +    deletes: deleted, 
    +    failed: failed
    +  };
    +};
    +
    +}(this.recline.Data))
    +
    +
    \ No newline at end of file diff --git a/docs/src/model.html b/docs/src/model.html index b73e36e9..26572c1a 100644 --- a/docs/src/model.html +++ b/docs/src/model.html @@ -1,4 +1,4 @@ - 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({
    @@ -163,6 +163,7 @@ also returned.

    self.recordCount = queryResult.total; var docs = _.map(queryResult.hits, function(hit) { var _doc = new my.Record(hit); + _doc.fields = self.fields; _doc.bind('change', function(doc) { self._changes.updates.push(doc.toJSON()); }); @@ -208,17 +209,8 @@ also returned.

    dfd.resolve(queryResult); }); return dfd.promise(); - },

    recordSummary

    - -

    Get a simple html summary of a Dataset record in form of key/value list

      recordSummary: function(record) {
    -    var html = '<div class="recline-record-summary">';
    -    this.fields.each(function(field) { 
    -      if (field.id != 'id') {
    -        html += '<div class="' + field.id + '"><strong>' + field.get('label') + '</strong>: ' + record.getFieldValue(field) + '</div>';
    -      }
    -    });
    -    html += '</div>';
    -    return html;
    +  },

    Deprecated (as of v0.5) - use record.summary()

      recordSummary: function(record) {
    +    return record.summary();
       },

    _backendFromString(backendString)

    See backend argument to initialize for details

      _backendFromString: function(backendString) {
    @@ -266,16 +258,21 @@ Dataset is an Object like:

    } dataset = new recline.Model.Dataset(datasetInfo); return dataset; -};

    A Record (aka Row)

    +};

    A Record

    -

    A single entry or row in the dataset

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

    A single record (or row) in the dataset

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

    initialize

    - initialize: function() { +

    Create a Record

    + +

    You usually will not do this directly but will have records created by +Dataset e.g. in query method

    + +

    Certain methods require presence of a fields attribute (identical to that on Dataset)

      initialize: function() {
         _.bindAll(this, 'getFieldValue');
    -  },

    getFieldValue

    + },

    getFieldValue

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

      getFieldValue: function(field) {
    @@ -284,7 +281,7 @@ for this record.

    val = field.renderer(val, field, this.toJSON()); } return val; - },

    getFieldValueUnrendered

    + },

    getFieldValueUnrendered

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

      getFieldValueUnrendered: function(field) {
    @@ -293,30 +290,42 @@ for this record.

    val = field.deriver(val, field, this); } return val; - },

    Override Backbone save, fetch and destroy so they do nothing + },

    summary

    + +

    Get a simple html summary of this record in form of key/value list

      summary: function(record) {
    +    var self = this;
    +    var html = '<div class="recline-record-summary">';
    +    this.fields.each(function(field) { 
    +      if (field.id != 'id') {
    +        html += '<div class="' + field.id + '"><strong>' + field.get('label') + '</strong>: ' + self.getFieldValue(field) + '</div>';
    +      }
    +    });
    +    html += '</div>';
    +    return html;
    +  },

    Override Backbone save, fetch and destroy so they do nothing Instead, Dataset object that created this Record should take care of handling these changes (discovery will occur via event notifications) WARNING: these will not persist unless you call save on Dataset

      fetch: function() {},
       save: function() {},
       destroy: function() { this.trigger('destroy', this); }
    -});

    A Backbone collection of Records

    my.RecordList = Backbone.Collection.extend({
    +});

    A Backbone collection of Records

    my.RecordList = Backbone.Collection.extend({
       constructor: function RecordList() {
         Backbone.Collection.prototype.constructor.apply(this, arguments);
       },
       model: my.Record
    -});

    A Field (aka Column) on a Dataset

    my.Field = Backbone.Model.extend({
    +});

    A Field (aka Column) on a Dataset

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

    defaults - define default values

      defaults: {
    +  },

    defaults - define default values

      defaults: {
         label: null,
         type: 'string',
         format: null,
         is_derived: false
    -  },

    initialize

    + },

    initialize

    @param {Object} data: standard Backbone model attributes

    -

    @param {Object} options: renderer and/or deriver functions.

      initialize: function(data, options) {

    if a hash not passed in the first argument throw error

        if ('0' in data) {
    +

    @param {Object} options: renderer and/or deriver functions.

      initialize: function(data, options) {

    if a hash not passed in the first argument throw error

        if ('0' in data) {
           throw new Error('Looks like you did not pass a proper hash with id to Field constructor');
         }
         if (this.attributes.label === null) {
    @@ -357,7 +366,7 @@ WARNING: these will not persist unless you call save on Dataset

    } } else if (format == 'plain') { return val; - } else {

    as this is the default and default type is string may get things + } else {

    as this is the default and default type is string may get things here that are not actually strings

            if (val && typeof val === 'string') {
               val = val.replace(/(https?:\/\/[^ ]+)/g, '<a href="$1">$1</a>');
             }
    @@ -372,7 +381,7 @@ here that are not actually strings

    Backbone.Collection.prototype.constructor.apply(this, arguments); }, model: my.Field -});

    Query

    my.Query = Backbone.Model.extend({
    +});

    Query

    my.Query = Backbone.Model.extend({
       constructor: function Query() {
         Backbone.Model.prototype.constructor.apply(this, arguments);
       },
    @@ -387,11 +396,16 @@ here that are not actually strings

    }, _filterTemplates: { term: { - type: 'term', - field: '', + type: 'term',

    TODO do we need this attribute here?

          field: '',
           term: ''
         },
    +    range: {
    +      type: 'range',
    +      start: '',
    +      stop: ''
    +    },
         geo_distance: {
    +      type: 'geo_distance',
           distance: 10,
           unit: 'km',
           point: {
    @@ -399,11 +413,12 @@ here that are not actually strings

    lat: 0 } } - },

    addFilter

    + },

    addFilter

    Add a new filter (appended 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 full specified so use template and over-write

        if (_.keys(filter).length <= 2) {
    +

    @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 full specified so use template and over-write +3 as for 'type', 'field' and 'fieldType'

        if (_.keys(filter).length <= 3) {
           ourfilter = _.extend(this._filterTemplates[filter.type], ourfilter);
         }
         var filters = this.get('filters');
    @@ -411,19 +426,19 @@ here that are not actually strings

    this.trigger('change:filters:new-blank'); }, updateFilter: function(index, value) { - },

    removeFilter

    + },

    removeFilter

    Remove a filter from filters at index filterIndex

      removeFilter: function(filterIndex) {
         var filters = this.get('filters');
         filters.splice(filterIndex, 1);
         this.set({filters: filters});
         this.trigger('change');
    -  },

    addFacet

    + },

    addFacet

    Add a Facet to this query

    See http://www.elasticsearch.org/guide/reference/api/search/facets/

      addFacet: function(fieldId) {
    -    var facets = this.get('facets');

    Assume id and fieldId should be the same (TODO: this need not be true if we want to add two different type of facets on same field)

        if (_.contains(_.keys(facets), fieldId)) {
    +    var facets = this.get('facets');

    Assume id and fieldId should be the same (TODO: this need not be true if we want to add two different type of facets on same field)

        if (_.contains(_.keys(facets), fieldId)) {
           return;
         }
         facets[fieldId] = {
    @@ -443,7 +458,7 @@ here that are not actually strings

    this.set({facets: facets}, {silent: true}); this.trigger('facet:add', this); } -});

    A Facet (Result)

    my.Facet = Backbone.Model.extend({
    +});

    A Facet (Result)

    my.Facet = Backbone.Model.extend({
       constructor: function Facet() {
         Backbone.Model.prototype.constructor.apply(this, arguments);
       },
    @@ -456,15 +471,15 @@ here that are not actually strings

    terms: [] }; } -});

    A Collection/List of Facets

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

    A Collection/List of Facets

    my.FacetList = Backbone.Collection.extend({
       constructor: function FacetList() {
         Backbone.Collection.prototype.constructor.apply(this, arguments);
       },
       model: my.Facet
    -});

    Object State

    +});

    Object State

    Convenience Backbone model for storing (configuration) state of objects like Views.

    my.ObjectState = Backbone.Model.extend({
    -});

    Backbone.sync

    +});

    Backbone.sync

    Override Backbone.sync to hand off to sync function in relevant backend

    Backbone.sync = function(method, model, options) {
       return model.backend.sync(method, model, options);
    diff --git a/docs/src/view.graph.html b/docs/src/view.graph.html
    index ebbebf8e..62c65a23 100644
    --- a/docs/src/view.graph.html
    +++ b/docs/src/view.graph.html
    @@ -1,4 +1,4 @@
    -      view.graph.js           

    view.graph.js

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

    view.graph.js

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

    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({
    -  tagName:  "div",
    -  className: "recline-graph",
    -
       template: ' \
    -  <div class="panel graph"> \
    -    <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 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> \
    -  </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;
    @@ -43,13 +43,9 @@ generate the element itself (you can then append view.el to the DOM.

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

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

        this.bind('view:show', function() {
    -      if (this.needToRedraw) {
    -        self.redraw();
    -      }
    -    });
    +    this.model.records.bind('reset', this.redraw);
         var stateData = _.extend({
    -        group: null,

    so that at least one series chooser box shows up

            series: [],
    +        group: null,

    so that at least one series chooser box shows up

            series: [],
             graphType: 'lines-and-points'
           },
           options.state
    @@ -64,7 +60,6 @@ generate the element itself (you can then append view.el to the DOM.

    self.redraw(); }); this.elSidebar = this.editor.el; - this.render(); }, render: function() { @@ -76,7 +71,7 @@ generate the element itself (you can then append view.el to the DOM.

    return this; }, - redraw: function() {

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

      + 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

    • @@ -85,11 +80,15 @@ generate the element itself (you can then append view.el to the DOM.

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

    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 = $.plot(this.$graph, series, options);
    -      this.setupTooltips();
    +      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

    @@ -98,150 +97,162 @@ generate the element itself (you can then append view.el to the DOM.

    needs to be function as can depend on state

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

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

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

        var tickFormatter = function (val) {
    -      if (self.model.records.models[val]) {
    -        var out = self.model.records.models[val].get(self.state.attributes.group);

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

            if (typeof(out) == 'number') {
    -          return val;
    -        } else {
    -          return out;
    -        }
    -      }
    -      return val;
    -    };
    +    var self = this;
     
    -    var xaxis = {};

    check for time series on x-axis

        if (this.model.fields.get(this.state.get('group')).get('type') === 'date') {
    -      xaxis.mode = 'time';
    -      xaxis.timeformat = '%y-%b';
    +    var tickFormatter = function (x) {
    +      return getFormattedX(x);
    +    };
    +    
    +    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 isDateTime = xfield.get('type') === 'date';
    +
    +      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 optionsPerGraphType = { 
    +    
    +    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: {
    -        series: { 
    -          lines: { show: true }
    -        },
    -        xaxis: xaxis
    +        legend: legend,
    +        colors: this.graphColors,
    +        lines: { show: true },
    +        xaxis: xaxis,
    +        yaxis: yaxis,
    +        mouse: mouse
           },
           points: {
    -        series: {
    -          points: { show: true }
    -        },
    +        legend: legend,
    +        colors: this.graphColors,
    +        points: { show: true, hitRadius: 5 },
             xaxis: xaxis,
    +        yaxis: yaxis,
    +        mouse: mouse,
             grid: { hoverable: true, clickable: true }
           },
           'lines-and-points': {
    -        series: {
    -          points: { show: true },
    -          lines: { show: true }
    -        },
    +        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: {
    -        series: {
    -          lines: {show: false},
    -          bars: {
    -            show: true,
    -            barWidth: 1,
    -            align: "center",
    -            fill: true,
    -            horizontal: true
    -          }
    +        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'
             },
    -        grid: { hoverable: true, clickable: true },
    -        yaxis: {
    -          tickSize: 1,
    -          tickLength: 1,
    -          tickFormatter: tickFormatter,
    -          min: -0.5,
    -          max: self.model.records.length - 0.5
    -        }
    -      }
    +        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];
       },
     
    -  setupTooltips: function() {
    -    var self = this;
    -    function showTooltip(x, y, contents) {
    -      $('<div id="flot-tooltip">' + contents + '</div>').css( {
    -        position: 'absolute',
    -        display: 'none',
    -        top: y + 5,
    -        left: x + 5,
    -        border: '1px solid #fdd',
    -        padding: '2px',
    -        'background-color': '#fee',
    -        opacity: 0.80
    -      }).appendTo("body").fadeIn(200);
    -    }
    -
    -    var previousPoint = null;
    -    this.$graph.bind("plothover", function (event, pos, item) {
    -      if (item) {
    -        if (previousPoint != item.datapoint) {
    -          previousPoint = item.datapoint;
    -          
    -          $("#flot-tooltip").remove();
    -          var x = item.datapoint[0];
    -          var y = item.datapoint[1];

    it's horizontal so we have to flip

              if (self.state.attributes.graphType === 'bars') {
    -            var _tmp = x;
    -            x = y;
    -            y = _tmp;
    -          }

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

              if (self.model.records.models[x]) {
    -            x = self.model.records.models[x].get(self.state.attributes.group);
    -          } else {
    -            x = x.toFixed(2);
    -          }
    -          y = y.toFixed(2);

    is it time series

              var xfield = self.model.fields.get(self.state.attributes.group);
    -          var isDateTime = xfield.get('type') === 'date';
    -          if (isDateTime) {
    -            x = new Date(parseInt(x)).toLocaleDateString();
    -          }
    -          
    -          var content = _.template('<%= group %> = <%= x %>, <%= series %> = <%= y %>', {
    -            group: self.state.attributes.group,
    -            x: x,
    -            series: item.series.label,
    -            y: y
    -          });
    -          showTooltip(item.pageX, item.pageY, content);
    -        }
    -      }
    -      else {
    -        $("#flot-tooltip").remove();
    -        previousPoint = null;            
    -      }
    -    });
    -  },
    -
    -  createSeries: function () {
    +  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 isDateTime = xfield.get('type') === 'date';
    -        if (isDateTime) {
    -          x = moment(x).toDate();
    -        }
    -        var yfield = self.model.fields.get(field);
    -        var y = doc.getFieldValue(yfield);
    -        if (typeof x === 'string') {
    -          x = parseFloat(x);
    +        var x = doc.getFieldValue(xfield);

    time series

            var isDateTime = xfield.get('type') === 'date';
    +        
    +        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;
               }
    -        }

    horizontal bar chart

            if (self.state.attributes.graphType == 'bars') {
    +        }
    +
    +        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});
    +      series.push({data: points, label: field, mouse:{lineColor: self.graphColors[series.length]}});
         });
         return series;
       }
    @@ -260,6 +271,7 @@ have no field type info). Thus at present we only do this for bars.

    <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> \ @@ -318,12 +330,12 @@ have no field type info). Thus at present we only do this for bars.

    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.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 = [""];
    +    }

    ensure at least one series box shows up

        var tmpSeries = [""];
         if (this.state.get('series').length > 0) {
           tmpSeries = this.state.get('series');
         }
    @@ -332,7 +344,7 @@ have no field type info). Thus at present we only do this for bars.

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

    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){
    @@ -357,7 +369,7 @@ have no field type info). Thus at present we only do this for bars.

    graphType: this.el.find('.editor-type select').val() }; this.state.set(updatedState); - },

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

    + },

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

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

    @@ -375,7 +387,7 @@ have no field type info). Thus at present we only do this for bars.

    _onAddSeries: function(e) { e.preventDefault(); this.addSeries(this.state.get('series').length); - },

    Public: Removes a series list item from the editor.

    + },

    Public: Removes a series list item from the editor.

    Also updates the labels of the remaining series elements.

      removeSeries: function (e) {
         e.preventDefault();
    diff --git a/docs/src/view.grid.html b/docs/src/view.grid.html
    index 4d2c0d23..7e613802 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 || {};
    @@ -26,77 +26,9 @@
         this.state = new recline.Model.ObjectState(state);
       },
     
    -  events: {
    -    'click .column-header-menu .data-table-menu li a': 'onColumnHeaderClick',
    -    'click .row-header-menu': 'onRowHeaderClick',
    -    'click .root-header-menu': 'onRootHeaderClick',
    -    'click .data-table-menu li a': 'onMenuClick',

    does not work here so done at end of render function + events: {

    does not work here so done at end of render function 'scroll .recline-grid tbody': 'onHorizontalScroll'

      },

    ====================================================== -Column and row menus

      onColumnHeaderClick: function(e) {
    -    this.tempState.currentColumn = $(e.target).closest('.column-header').attr('data-field');
    -  },
    -
    -  onRowHeaderClick: function(e) {
    -    this.tempState.currentRow = $(e.target).parents('tr:first').attr('data-id');
    -  },
    -  
    -  onRootHeaderClick: function(e) {
    -    var tmpl = ' \
    -        {{#columns}} \
    -        <li><a data-action="showColumn" data-column="{{.}}" href="JavaScript:void(0);">Show column: {{.}}</a></li> \
    -        {{/columns}}';
    -    var tmp = Mustache.render(tmpl, {'columns': this.state.get('hiddenFields')});
    -    this.el.find('.root-header-menu .dropdown-menu').html(tmp);
    -  },
    -
    -  onMenuClick: function(e) {
    -    var self = this;
    -    e.preventDefault();
    -    var actions = {
    -      bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.tempState.currentColumn}); },
    -      facet: function() { 
    -        self.model.queryState.addFacet(self.tempState.currentColumn);
    -      },
    -      facet_histogram: function() {
    -        self.model.queryState.addHistogramFacet(self.tempState.currentColumn);
    -      },
    -      filter: function() {
    -        self.model.queryState.addTermFilter(self.tempState.currentColumn, '');
    -      },
    -      sortAsc: function() { self.setColumnSort('asc'); },
    -      sortDesc: function() { self.setColumnSort('desc'); },
    -      hideColumn: function() { self.hideColumn(); },
    -      showColumn: function() { self.showColumn(e); },
    -      deleteRow: function() {
    -        var self = this;
    -        var doc = _.find(self.model.records.models, function(doc) {

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

              return doc.id == self.tempState.currentRow;
    -        });
    -        doc.destroy().then(function() { 
    -            self.model.records.remove(doc);
    -            self.trigger('recline:flash', {message: "Row deleted successfully"});
    -          }).fail(function(err) {
    -            self.trigger('recline:flash', {message: "Errorz! " + err});
    -          });
    -      }
    -    };
    -    actions[$(e.target).attr('data-action')]();
    -  },
    -
    -  showTransformColumnDialog: function() {
    -    var self = this;
    -    var view = new my.ColumnTransform({
    -      model: this.model
    -    });

    pass the flash message up the chain

        view.bind('recline:flash', function(flash) {
    -      self.trigger('recline:flash', flash);
    -    });
    -    view.state = this.tempState;
    -    view.render();
    -    this.el.append(view.el);
    -    view.el.modal();
    -  },
    -
    -  setColumnSort: function(order) {
    +Column and row menus

      setColumnSort: function(order) {
         var sort = [{}];
         sort[0][this.tempState.currentColumn] = {order: order};
         this.model.query({sort: sort});
    @@ -105,7 +37,7 @@ from DOM) while id may be int

    hideColumn: function() { var hiddenFields = this.state.get('hiddenFields'); hiddenFields.push(this.tempState.currentColumn); - this.state.set({hiddenFields: hiddenFields});

    change event not being triggered (because it is an array?) so trigger manually

        this.state.trigger('change');
    +    this.state.set({hiddenFields: hiddenFields});

    change event not being triggered (because it is an array?) so trigger manually

        this.state.trigger('change');
         this.render();
       },
       
    @@ -118,40 +50,15 @@ from DOM) while id may be int

    onHorizontalScroll: function(e) { var currentScroll = $(e.target).scrollLeft(); this.el.find('.recline-grid thead tr').scrollLeft(currentScroll); - },

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

    + },

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

    Templating

      template: ' \
         <div class="table-container"> \
         <table class="recline-grid table-striped table-condensed" cellspacing="0"> \
           <thead class="fixed-header"> \
             <tr> \
    -          {{#notEmpty}} \
    -            <th class="column-header"> \
    -              <div class="btn-group root-header-menu"> \
    -                <a class="btn dropdown-toggle" data-toggle="dropdown"><span class="caret"></span></a> \
    -                <ul class="dropdown-menu data-table-menu"> \
    -                </ul> \
    -              </div> \
    -              <span class="column-header-name"></span> \
    -            </th> \
    -          {{/notEmpty}} \
               {{#fields}} \
    -            <th class="column-header {{#hidden}}hidden{{/hidden}}" data-field="{{id}}" style="width: {{width}}px; max-width: {{width}}px; min-width: {{width}}px;"> \
    -              <div class="btn-group column-header-menu"> \
    -                <a class="btn dropdown-toggle" data-toggle="dropdown"><i class="icon-cog"></i><span class="caret"></span></a> \
    -                <ul class="dropdown-menu data-table-menu pull-right"> \
    -                  <li><a data-action="facet" href="JavaScript:void(0);">Term Facet</a></li> \
    -                  <li><a data-action="facet_histogram" href="JavaScript:void(0);">Date Histogram Facet</a></li> \
    -                  <li><a data-action="filter" href="JavaScript:void(0);">Text Filter</a></li> \
    -                  <li class="divider"></li> \
    -                  <li><a data-action="sortAsc" href="JavaScript:void(0);">Sort ascending</a></li> \
    -                  <li><a data-action="sortDesc" href="JavaScript:void(0);">Sort descending</a></li> \
    -                  <li class="divider"></li> \
    -                  <li><a data-action="hideColumn" href="JavaScript:void(0);">Hide this column</a></li> \
    -                  <li class="divider"></li> \
    -                  <li class="write-op"><a data-action="bulkEdit" href="JavaScript:void(0);">Transform...</a></li> \
    -                </ul> \
    -              </div> \
    +            <th class="column-header {{#hidden}}hidden{{/hidden}}" data-field="{{id}}" style="width: {{width}}px; max-width: {{width}}px; min-width: {{width}}px;" title="{{label}}"> \
                   <span class="column-header-name">{{label}}</span> \
                 </th> \
               {{/fields}} \
    @@ -166,9 +73,9 @@ from DOM) while id may be int

    toTemplateJSON: function() { var self = this; var modelData = this.model.toJSON(); - modelData.notEmpty = ( this.fields.length > 0 );

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

        modelData.fields = _.map(this.fields, function(field) {
    +    modelData.notEmpty = ( this.fields.length > 0 );

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

        modelData.fields = _.map(this.fields, function(field) {
           return field.toJSON();
    -    });

    last header width = scroll bar - border (2px) */

        modelData.lastHeaderWidth = this.scrollbarDimensions.width - 2;
    +    });

    last header width = scroll bar - border (2px) */

        modelData.lastHeaderWidth = this.scrollbarDimensions.width - 2;
         return modelData;
       },
       render: function() {
    @@ -177,9 +84,9 @@ from DOM) while id may be int

    return _.indexOf(self.state.get('hiddenFields'), field.id) == -1; }); this.scrollbarDimensions = this.scrollbarDimensions || this._scrollbarSize(); // skip measurement if already have dimensions - var numFields = this.fields.length;

    compute field widths (-20 for first menu col + 10px for padding on each col and finally 16px for the scrollbar)

        var fullWidth = self.el.width() - 20 - 10 * numFields - this.scrollbarDimensions.width;
    -    var width = parseInt(Math.max(50, fullWidth / numFields));

    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 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) {
             field.set({width: width+remainder});
           } else {
             field.set({width: width});
    @@ -196,14 +103,14 @@ from DOM) while id may be int

    fields: self.fields }); newView.render(); - });

    hide extra header col if no scrollbar to avoid unsightly overhang

        var $tbody = this.el.find('tbody')[0];
    +    });

    hide extra header col if no scrollbar to avoid unsightly overhang

        var $tbody = this.el.find('tbody')[0];
         if ($tbody.scrollHeight <= $tbody.offsetHeight) {
           this.el.find('th.last-header').hide();
         }
         this.el.find('.recline-grid').toggleClass('no-hidden', (self.state.get('hiddenFields').length === 0));
         this.el.find('.recline-grid tbody').scroll(this.onHorizontalScroll);
         return this;
    -  },

    _scrollbarSize

    + },

    _scrollbarSize

    Measure width of a vertical scrollbar and height of a horizontal scrollbar.

    @@ -213,7 +120,7 @@ from DOM) while id may be int

    $c.remove(); return dim; } -});

    GridRow View for rendering an individual record.

    +});

    GridRow View for rendering an individual record.

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

    @@ -236,14 +143,6 @@ var row = new GridRow({ }, template: ' \ - <td> \ - <div class="btn-group row-header-menu"> \ - <a class="btn dropdown-toggle" data-toggle="dropdown"><span class="caret"></span></a> \ - <ul class="dropdown-menu data-table-menu"> \ - <li class="write-op"><a data-action="deleteRow" href="JavaScript:void(0);">Delete this row</a></li> \ - </ul> \ - </div> \ - </td> \ {{#cells}} \ <td data-field="{{field}}" style="width: {{width}}px; max-width: {{width}}px; min-width: {{width}}px;"> \ <div class="data-table-cell-content"> \ @@ -277,7 +176,7 @@ var row = new GridRow({ var html = Mustache.render(this.template, this.toTemplateJSON()); $(this.el).html(html); return this; - },

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

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

      cellEditorTemplate: ' \
         <div class="menu-container data-table-cell-editor"> \
           <textarea class="data-table-cell-editor-editor" bind="textarea">{{value}}</textarea> \
    diff --git a/docs/src/view.map.html b/docs/src/view.map.html
    index 817f29a9..b30e2692 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 || {};
    @@ -21,11 +21,10 @@ have the following (optional) configuration options:

    latField: {id of field containing latitude in the dataset} }
    my.Map = Backbone.View.extend({
    -  tagName:  'div',
    -  className: 'recline-map',
    -
       template: ' \
    -    <div class="panel map"></div> \
    +    <div class="recline-map"> \
    +      <div class="panel map"></div> \
    +    </div> \
     ',

    These are the default (case-insensitive) names of field that are used if found. If not found, the user will need to define the fields via the editor.

      latitudeFieldNames: ['lat','latitude'],
       longitudeFieldNames: ['lon','longitude'],
    @@ -33,7 +32,19 @@ If not found, the user will need to define the fields via the editor.

    initialize: function(options) { var self = this; - this.el = $(this.el);

    Listen to changes in the fields

        this.model.fields.bind('change', function() {
    +    this.el = $(this.el);
    +    this.visible = true;
    +    this.mapReady = false;
    +
    +    var stateData = _.extend({
    +        geomField: null,
    +        lonField: null,
    +        latField: null,
    +        autoZoom: true
    +      },
    +      options.state
    +    );
    +    this.state = new recline.Model.ObjectState(stateData);

    Listen to changes in the fields

        this.model.fields.bind('change', function() {
           self._setupGeometryField()
           self.render()
         });

    Listen to changes in the records

        this.model.records.bind('add', function(doc){self.redraw('add',doc)});
    @@ -44,29 +55,6 @@ If not found, the user will need to define the fields via the editor.

    this.model.records.bind('remove', function(doc){self.redraw('remove',doc)}); this.model.records.bind('reset', function(){self.redraw('reset')}); - this.bind('view:show',function(){

    If the div was hidden, Leaflet needs to recalculate some sizes -to display properly

          if (self.map){
    -        self.map.invalidateSize();
    -        if (self._zoomPending && self.state.get('autoZoom')) {
    -          self._zoomToFeatures();
    -          self._zoomPending = false;
    -        }
    -      }
    -      self.visible = true;
    -    });
    -    this.bind('view:hide',function(){
    -      self.visible = false;
    -    });
    -
    -    var stateData = _.extend({
    -        geomField: null,
    -        lonField: null,
    -        latField: null,
    -        autoZoom: true
    -      },
    -      options.state
    -    );
    -    this.state = new recline.Model.ObjectState(stateData);
         this.menu = new my.MapMenu({
           model: this.model,
           state: this.state.toJSON()
    @@ -76,11 +64,7 @@ to display properly

    self.redraw(); }); this.elSidebar = this.menu.el; - - this.mapReady = false; - this.render(); - this.redraw(); - },

    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;
    @@ -88,8 +72,9 @@ to display properly

    htmls = Mustache.render(this.template, this.model.toTemplateJSON()); $(this.el).html(htmls); this.$map = this.el.find('.panel.map'); + this.redraw(); return this; - },

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

    + },

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

    Actions can be:

    @@ -100,7 +85,7 @@ to display properly

  • 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){
    @@ -126,6 +111,21 @@ to display properly

    } }, + show: function() {

    If the div was hidden, Leaflet needs to recalculate some sizes +to display properly

        if (this.map){
    +      this.map.invalidateSize();
    +      if (this._zoomPending && this.state.get('autoZoom')) {
    +        this._zoomToFeatures();
    +        this._zoomPending = false;
    +      }
    +    }
    +    this.visible = true;
    +  },
    +
    +  hide: function() {
    +    this.visible = false;
    +  },
    +
       _geomReady: function() {
         return Boolean(this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
       },

    Private: Add one or n features to the map

    diff --git a/docs/src/view.multiview.html b/docs/src/view.multiview.html index 648b2baa..902aff81 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

    @@ -46,6 +46,30 @@ var views = [ ]; +

    sidebarViews: (optional) the sidebar views (Filters, Fields) for +MultiView to show. This is an array of view hashes. If not provided +initialize with (recline.View.)FilterEditor and Fields views (with obvious +id and labels!).

    + +
    +var sidebarViews = [
    +  {
    +    id: 'filterEditor', // used for routing
    +    label: 'Filters', // used for view switcher
    +    view: new recline.View.FielterEditor({
    +      model: dataset
    +    })
    +  },
    +  {
    +    id: 'fieldsView',
    +    label: 'Fields',
    +    view: new recline.View.Fields({
    +      model: dataset
    +    })
    +  }
    +];
    +
    +

    state: standard state config for this view. This state is slightly special as it includes config of many of the subviews.

    @@ -139,9 +163,25 @@ initialized the MultiView with the relevant views themselves.

    model: this.model }) }]; - }

    these must be called after pageViews are created

        this.render();
    +    }

    Hashes of sidebar elements

        if(options.sidebarViews) {
    +      this.sidebarViews = options.sidebarViews;
    +    } else {
    +      this.sidebarViews = [{
    +        id: 'filterEditor',
    +        label: 'Filters',
    +        view: new my.FilterEditor({
    +          model: this.model
    +        })
    +      }, {
    +        id: 'fieldsView',
    +        label: 'Fields',
    +        view: new my.Fields({
    +          model: this.model
    +        })
    +      }];
    +    }

    these must be called after pageViews are created

        this.render();
         this._bindStateChanges();
    -    this._bindFlashNotifications();

    now do updates based on state (need to come after render)

        if (this.state.get('readOnly')) {
    +    this._bindFlashNotifications();

    now do updates based on state (need to come after render)

        if (this.state.get('readOnly')) {
           this.setReadOnly();
         }
         if (this.state.get('currentView')) {
    @@ -173,7 +213,7 @@ initialized the MultiView with the relevant views themselves.

    msg = 'There was an error querying the backend'; } self.notify({message: msg, category: 'error', persist: true}); - });

    retrieve basic data like fields etc + });

    retrieve basic data like fields etc note this.model and dataset returned are the same TODO: set query state ...?

        this.model.queryState.set(self.state.get('query'), {silent: true});
         this.model.fetch()
    @@ -190,14 +230,20 @@ TODO: set query state ...?

    var tmplData = this.model.toTemplateJSON(); tmplData.views = this.pageViews; var template = Mustache.render(this.template, tmplData); - $(this.el).html(template);

    now create and append other views

        var $dataViewContainer = this.el.find('.data-view-container');
    -    var $dataSidebar = this.el.find('.data-view-sidebar');

    the main views

        _.each(this.pageViews, function(view, pageName) {
    +    $(this.el).html(template);

    now create and append other views

        var $dataViewContainer = this.el.find('.data-view-container');
    +    var $dataSidebar = this.el.find('.data-view-sidebar');

    the main views

        _.each(this.pageViews, function(view, pageName) {
    +      view.view.render();
           $dataViewContainer.append(view.view.el);
           if (view.view.elSidebar) {
             $dataSidebar.append(view.view.elSidebar);
           }
         });
     
    +    _.each(this.sidebarViews, function(view) {
    +      this['$'+view.id] = view.view.el;
    +      $dataSidebar.append(view.view.el);
    +    });
    +
         var pager = new recline.View.Pager({
           model: this.model.queryState
         });
    @@ -208,35 +254,28 @@ TODO: set query state ...?

    }); this.el.find('.query-editor-here').append(queryEditor.el); - var filterEditor = new recline.View.FilterEditor({ - model: this.model - }); - this.$filterEditor = filterEditor.el; - $dataSidebar.append(filterEditor.el); - - var fieldsView = new recline.View.Fields({ - model: this.model - }); - this.$fieldsView = fieldsView.el; - $dataSidebar.append(fieldsView.el); }, 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');

    show the specific page

        _.each(this.pageViews, function(view, idx) {
           if (view.id === pageName) {
             view.view.el.show();
             if (view.view.elSidebar) {
               view.view.elSidebar.show();
             }
    -        view.view.trigger('view:show');
    +        if (view.view.show) {
    +          view.view.show();
    +        }
           } else {
             view.view.el.hide();
             if (view.view.elSidebar) {
               view.view.elSidebar.hide();
             }
    -        view.view.trigger('view:hide');
    +        if (view.view.hide) {
    +          view.view.hide();
    +        }
           }
         });
       },
    @@ -258,15 +297,15 @@ TODO: set query state ...?

    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__,
    @@ -279,7 +318,7 @@ TODO: set query state ...?

    }, _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) {
    @@ -289,7 +328,7 @@ TODO: set query state ...?

    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');
             });
           }
    @@ -303,7 +342,7 @@ TODO: set query state ...?

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

    notify

    + },

    notify

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

    @@ -342,7 +381,7 @@ flash object. Flash attributes (all are optional):

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

    clearNotifications

    + },

    clearNotifications

    Clear all existing notifications

      clearNotifications: function() {
         var $notifications = $('.recline-data-explorer .alert-messages .alert');
    @@ -350,7 +389,7 @@ flash object. Flash attributes (all are optional):

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

    MultiView.restore

    +});

    MultiView.restore

    Restore a MultiView instance from a serialized state including the associated dataset

    my.MultiView.restore = function(state) {
       var dataset = recline.Model.Dataset.restore(state);
    @@ -359,7 +398,7 @@ flash object. Flash attributes (all are optional):

    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 {};
    @@ -369,7 +408,7 @@ flash object. Flash attributes (all are optional):

    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 {};
       }
    @@ -382,13 +421,13 @@ flash object. Flash attributes (all are optional):

    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) {
    @@ -403,7 +442,7 @@ flash object. Flash attributes (all are optional):

    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 960f8dd7..72be29fd 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 || {};
    @@ -12,9 +12,6 @@
     

    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({
    -  tagName:  "div",
    -  className: "recline-slickgrid",
    -
       initialize: function(modelEtc) {
         var self = this;
         this.el = $(this.el);
    @@ -33,21 +30,6 @@
           }, modelEtc.state
         );
         this.state = new recline.Model.ObjectState(state);
    -
    -    this.bind('view: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 (!self.rendered){
    -        if (!self.grid){
    -          self.render();
    -        }
    -        self.grid.init();
    -        self.rendered = true;
    -      }
    -      self.visible = true;
    -    });
    -    this.bind('view:hide',function(){
    -      self.visible = false;
    -    });
    -
       },
     
       events: {
    @@ -62,7 +44,7 @@ sizes so we must render it explicitly when the view is visible

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

    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);
    @@ -88,16 +70,16 @@ row = row index, cell = cell index, value = value, columnDef = column definition
           }
     
           columns.push(column);
    -    });

    Restrict the visible columns

        var visibleColumns = columns.filter(function(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')){
    +    });

    Order them if there is ordering info on the state

        if (this.state.get('columnsOrder')){
           visibleColumns = visibleColumns.sort(function(a,b){
    -        return _.indexOf(self.state.get('columnsOrder'),a.id) > _.indexOf(self.state.get('columnsOrder'),b.id);
    +        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);
    +        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){
    @@ -116,7 +98,7 @@ column picker

    data.push(row); }); - 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 = _.keys(sortInfo[0])[0];
           var sortAsc = !(sortInfo[0][column].order == 'desc');
    @@ -152,11 +134,26 @@ column picker

    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 +sizes so we must render it explicitly when the view is visible

        if (!this.rendered){
    +      if (!this.grid){
    +        this.render();
    +      }
    +      this.grid.init();
    +      this.rendered = true;
    +    }
    +    this.visible = true;
    +  },
    +
    +  hide: function() {
    +    this.visible = false;
    +  }
     });
     
     })(jQuery, recline.View);
    diff --git a/docs/src/view.timeline.html b/docs/src/view.timeline.html
    index a32cb701..295900ce 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 || {};
    @@ -8,8 +8,6 @@
     }

    Timeline

    Timeline view using http://timeline.verite.co/

    my.Timeline = Backbone.View.extend({
    -  tagName:  'div',
    -
       template: ' \
         <div class="recline-timeline"> \
           <div id="vmm-timeline-id"></div> \
    @@ -24,10 +22,6 @@ If not found, the user will need to define these fields on initialization

    this.el = $(this.el); this.timeline = new VMM.Timeline(); this._timelineIsInitialized = false; - this.bind('view:show', function() {

    only call _initTimeline once view in DOM as Timeline uses $ internally to look up element

          if (self._timelineIsInitialized === false) {
    -        self._initTimeline();
    -      }
    -    });
         this.model.fields.bind('reset', function() {
           self._setupTemporalField();
         });
    @@ -42,16 +36,20 @@ If not found, the user will need to define these fields on initialization

    ); this.state = new recline.Model.ObjectState(stateData); this._setupTemporalField(); - this.render();

    can only call _initTimeline once view in DOM as Timeline uses $ -internally to look up element

        if ($(this.elementId).length > 0) {
    -      this._initTimeline();
    -    }
       },
     
       render: function() {
         var tmplData = {};
         var htmls = Mustache.render(this.template, tmplData);
    -    this.el.html(htmls);
    +    this.el.html(htmls);

    can only call _initTimeline once view in DOM as Timeline uses $ +internally to look up element

        if ($(this.elementId).length > 0) {
    +      this._initTimeline();
    +    }
    +  },
    +
    +  show: function() {

    only call _initTimeline once view in DOM as Timeline uses $ internally to look up element

        if (this._timelineIsInitialized === false) {
    +      this._initTimeline();
    +    }
       },
     
       _initTimeline: function() {
    @@ -82,7 +80,7 @@ internally to look up element

    "startDate": start, "endDate": end, "headline": String(record.get('title') || ''), - "text": record.get('description') || this.model.recordSummary(record) + "text": record.get('description') || record.summary() }; return tlEntry; } else { diff --git a/docs/src/view.transform.html b/docs/src/view.transform.html index fbe2dcc2..fdca17c9 100644 --- a/docs/src/view.transform.html +++ b/docs/src/view.transform.html @@ -1,24 +1,25 @@ - view.transform.js

    view.transform.js

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

    view.transform.js

    /*jshint multistr:true */
     
     this.recline = this.recline || {};
     this.recline.View = this.recline.View || {};

    Views module following classic module pattern

    (function($, my) {

    ColumnTransform

    View (Dialog) for doing data transformations

    my.Transform = Backbone.View.extend({
    -  className: 'recline-transform',
       template: ' \
    -    <div class="script"> \
    -      <h2> \
    -        Transform Script \
    -        <button class="okButton btn btn-primary">Run on all records</button> \
    -      </h2> \
    -      <textarea class="expression-preview-code"></textarea> \
    -    </div> \
    -    <div class="expression-preview-parsing-status"> \
    -      No syntax error. \
    -    </div> \
    -    <div class="preview"> \
    -      <h3>Preview</h3> \
    -      <div class="expression-preview-container"></div> \
    +    <div class="recline-transform"> \
    +      <div class="script"> \
    +        <h2> \
    +          Transform Script \
    +          <button class="okButton btn btn-primary">Run on all records</button> \
    +        </h2> \
    +        <textarea class="expression-preview-code"></textarea> \
    +      </div> \
    +      <div class="expression-preview-parsing-status"> \
    +        No syntax error. \
    +      </div> \
    +      <div class="preview"> \
    +        <h3>Preview</h3> \
    +        <div class="expression-preview-container"></div> \
    +      </div> \
         </div> \
       ',
     
    @@ -29,7 +30,6 @@
     
       initialize: function(options) {
         this.el = $(this.el);
    -    this.render();
       },
     
       render: function() {
    @@ -42,14 +42,13 @@ TODO: put this into the template?

    var col = 'unknown'; } editor.val("function(doc) {\n doc['"+ col +"'] = doc['"+ col +"'];\n return doc;\n}"); - editor.focus().get(0).setSelectionRange(18, 18); editor.keydown(); }, onSubmit: function(e) { var self = this; var funcText = this.el.find('.expression-preview-code').val(); - var editFunc = costco.evalFunction(funcText); + var editFunc = recline.Data.Transform.evalFunction(funcText); if (editFunc.errorMessage) { this.trigger('recline:flash', {message: "Error with function! " + editFunc.errorMessage}); return; @@ -87,13 +86,13 @@ TODO: put this into the template?

    onEditorKeydown: function(e) { var self = this;

    if you don't setTimeout it won't grab the latest character if you call e.target.value

        window.setTimeout( function() {
           var errors = self.el.find('.expression-preview-parsing-status');
    -      var editFunc = costco.evalFunction(e.target.value);
    +      var editFunc = recline.Data.Transform.evalFunction(e.target.value);
           if (!editFunc.errorMessage) {
             errors.text('No syntax error.');
             var docs = self.model.records.map(function(doc) {
               return doc.toJSON();
             });
    -        var previewData = costco.previewTransform(docs, editFunc);
    +        var previewData = recline.Data.Transform.previewTransform(docs, editFunc);
             var $el = self.el.find('.expression-preview-container');
             var fields = self.model.fields.toJSON();
             var rows = _.map(previewData.slice(0,4), function(row) {
    diff --git a/docs/src/widget.facetviewer.html b/docs/src/widget.facetviewer.html
    index fb407748..62af60b6 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 22fa2b0d..2821e218 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

    diff --git a/docs/src/widget.filtereditor.html b/docs/src/widget.filtereditor.html index 9c063b9a..30085297 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 || {};
    @@ -16,6 +16,7 @@
               <label>Filter type</label> \
               <select class="filterType"> \
                 <option value="term">Term (text)</option> \
    +            <option value="range">Range</option> \
                 <option value="geo_distance">Geo distance</option> \
               </select> \
               <label>Field</label> \
    @@ -48,6 +49,20 @@
               <input type="text" value="{{term}}" name="term" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
             </fieldset> \
           </div> \
    +    ',
    +    range: ' \
    +      <div class="filter-{{type}} filter"> \
    +        <fieldset> \
    +          <legend> \
    +            {{field}} <small>{{type}}</small> \
    +            <a class="js-remove-filter" href="#" title="Remove this filter">&times;</a> \
    +          </legend> \
    +          <label class="control-label" for="">From</label> \
    +          <input type="text" value="{{start}}" name="start" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
    +          <label class="control-label" for="">To</label> \
    +          <input type="text" value="{{stop}}" name="stop" data-filter-field="{{field}}" data-filter-id="{{id}}" data-filter-type="{{type}}" /> \
    +        </fieldset> \
    +      </div> \
         ',
         geo_distance: ' \
           <div class="filter-{{type}} filter"> \
    @@ -104,8 +119,9 @@
         var $target = $(e.target);
         $target.hide();
         var filterType = $target.find('select.filterType').val();
    -    var field = $target.find('select.fields').val();
    -    this.model.queryState.addFilter({type: filterType, field: field});

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

        this.render();
    +    var field      = $target.find('select.fields').val();
    +    var fieldType  = this.model.fields.find(function (e) { return e.get('id') === field }).get('type');
    +    this.model.queryState.addFilter({type: filterType, field: field, fieldType: fieldType});

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

        this.render();
       },
       onRemoveFilter: function(e) {
         e.preventDefault();
    @@ -120,19 +136,27 @@
         var $form = $(e.target);
         _.each($form.find('input'), function(input) {
           var $input = $(input);
    -      var filterType = $input.attr('data-filter-type');
    -      var fieldId = $input.attr('data-filter-field');
    +      var filterType  = $input.attr('data-filter-type');
    +      var fieldId     = $input.attr('data-filter-field');
           var filterIndex = parseInt($input.attr('data-filter-id'));
    -      var name = $input.attr('name');
    -      var value = $input.val();
    -      if (filterType === 'term') {
    -        filters[filterIndex].term = value;
    -      } else if (filterType === 'geo_distance') {
    -        if (name === 'distance') {
    -          filters[filterIndex].distance = parseFloat(value);
    -        } else {
    -          filters[filterIndex].point[name] = parseFloat(value);
    -        }
    +      var name        = $input.attr('name');
    +      var value       = $input.val();
    +
    +      switch (filterType) {
    +        case 'term':
    +          filters[filterIndex].term = value;
    +          break;
    +        case 'range':
    +          filters[filterIndex][name] = value;
    +          break;
    +        case 'geo_distance':
    +          if(name === 'distance') {
    +            filters[filterIndex].distance = parseFloat(value);
    +          }
    +          else {
    +            filters[filterIndex].point[name] = parseFloat(value);
    +          }
    +          break;
           }
         });
         self.model.queryState.set({filters: filters});
    diff --git a/docs/src/widget.pager.html b/docs/src/widget.pager.html
    index a1877309..9ecfadc9 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 || {};
    diff --git a/docs/src/widget.queryeditor.html b/docs/src/widget.queryeditor.html
    index 1d9adf56..846d8ee5 100644
    --- a/docs/src/widget.queryeditor.html
    +++ b/docs/src/widget.queryeditor.html
    @@ -1,4 +1,4 @@
    -      widget.queryeditor.js