diff --git a/README.md b/README.md index ed2bc918..bcd93e3a 100755 --- a/README.md +++ b/README.md @@ -25,6 +25,60 @@ Designed for standalone use or as a library to integrate into your own app. Running the tests by opening `test/index.html` in your browser. +## Changelog + +### v0.5 - Master + +In progress. + +### v0.4 - April 26th 2012 + +[23 closed issues](https://github.com/okfn/recline/issues?milestone=2&page=1&state=closed) including: + +* Map view using Leaflet - #69, #64, #89, #97 +* Term filter support - #66 +* Faceting support- #62 +* Tidy up CSS and JS - #81 and #78 +* Manage and serialize view and dataset state (plus support for embed and permalinks) - #88, #67 +* Graph view improvements e.g. handle date types correctly - #75 +* Write support for ES backend - #61 +* Remove JQuery-UI dependency in favour of bootstrap modal - #46 +* Improved CSV import support - #92 + +### v0.3 - March 31st 2012 + +[16 closed issues](https://github.com/okfn/recline/issues?milestone=1&state=closed) including: + +* ElasticSearch (and hence DataHub/CKAN) backend - #54 +* Loading of local CSV files - #36 +* Fully worked out Data Query support - #34, #49, #53, #57 +* New Field model object for richer field information - #25 +* Upgrade to Bootstrap v2.0 - #55 +* Recline Data Explorer app improvements e.g. #39 (import menu) +* Graph improvements - #58 (more graph types, graph interaction) + +### v0.2 - Feb 24th 2012 + +[17 closed issues](https://github.com/okfn/recline/issues?milestone=3&state=closed) including: + +* Major refactor of backend and model relationship - #35 and #43 +* Support Google Docs Spreadsheets as a Backend - #15 +* Support for online CSV and Excel files via DataProxy backend - #31 +* Data Explorer is customizable re loaded views - #42 +* Start of documentation - #33 +* Views in separate files - #41 +* Better error reporting from backends on JSONP errors - #30 +* Sorting and show/hide of columns in data grid - #23, #29 +* Support for pagination - #27 +* Split backends into separate files to make them easier to maintain and reuse separately #50 + +### v0.1 - Jan 28th 2012 + +* Core models and structure including Dataset and Document +* Memory and webstore backends +* Grid, Graph and Data Explorer views +* Bootstrap-based theme - #22 + ## Copyright and License Copyright 2011 Max Ogden and Rufus Pollock. diff --git a/_config.yml b/_config.yml new file mode 100644 index 00000000..c86cd02e --- /dev/null +++ b/_config.yml @@ -0,0 +1,5 @@ +pygments: true +auto: true + +title: Recline Data Explorer and Library + diff --git a/_includes/recline-deps.html b/_includes/recline-deps.html new file mode 100644 index 00000000..a03aa292 --- /dev/null +++ b/_includes/recline-deps.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/_layouts/container.html b/_layouts/container.html new file mode 100644 index 00000000..4abf7ae8 --- /dev/null +++ b/_layouts/container.html @@ -0,0 +1,8 @@ +--- +layout: default +--- + +
Flush the field buffer
field = '';
fieldQuoted = false;
- } else {If it's not a ", add it to the field buffer
if (cur !== '"') {
+ } else {If it's not a delimiter, add it to the field buffer
if (cur !== delimiter) {
field += cur;
} else {
if (!inQuote) {We are not in a quote, start a quote
inQuote = true;
fieldQuoted = true;
- } else {Next char is ", this is an escaped "
if (s.charAt(i + 1) === '"') {
- field += '"';Skip the next char
i += 1;
+ } else {Next char is delimiter, this is an escaped delimiter
if (s.charAt(i + 1) === delimiter) {
+ field += delimiter;Skip the next char
i += 1;
} else {It's not escaping, so end quote
inQuote = false;
}
}
diff --git a/docs/backend/memory.html b/docs/backend/memory.html
index 6304cee6..e1c865a7 100644
--- a/docs/backend/memory.html
+++ b/docs/backend/memory.html
@@ -35,6 +35,7 @@ If not defined (or id not provided) id will be autogenerated.
backend.addDataset(datasetInfo);
var dataset = new recline.Model.Dataset({id: metadata.id}, backend);
dataset.fetch();
+ dataset.query();
return dataset;
};we need the model.fields to render properly
this.model.bind('change', this.render);
+ _.bindAll(this, 'render', 'redraw');
+ this.needToRedraw = false;
+ this.model.bind('change', this.render);
this.model.fields.bind('reset', this.render);
this.model.fields.bind('add', this.render);
this.model.currentDocuments.bind('add', this.redraw);
- this.model.currentDocuments.bind('reset', this.redraw);
+ this.model.currentDocuments.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,
- series: [],
+ group: null,so that at least one series chooser box shows up
series: [],
graphType: 'lines-and-points'
},
options.state
@@ -105,17 +121,41 @@ generate the element itself (you can then append view.el to the DOM.
},
render: function() {
- htmls = $.mustache(this.template, this.model.toTemplateJSON());
- $(this.el).html(htmls);now set a load of stuff up
this.$graph = this.el.find('.panel.graph');for use later when adding additional series -could be simpler just to have a common template!
this.$seriesClone = this.el.find('.editor-series').clone();
- this._updateSeries();
+ var self = this;
+ var tmplData = this.model.toTemplateJSON();
+ var htmls = $.mustache(this.template, tmplData);
+ $(this.el).html(htmls);
+ this.$graph = this.el.find('.panel.graph');set up editor from state
if (this.state.get('graphType')) {
+ this._selectOption('.editor-type', this.state.get('graphType'));
+ }
+ if (this.state.get('group')) {
+ this._selectOption('.editor-group', this.state.get('group'));
+ }ensure at least one series box shows up
var tmpSeries = [""];
+ if (this.state.get('series').length > 0) {
+ tmpSeries = this.state.get('series');
+ }
+ _.each(tmpSeries, function(series, idx) {
+ self.addSeries(idx);
+ self._selectOption('.editor-series.js-series-' + idx, series);
+ });
return this;
+ },Private: Helper function to select an option from a select list
_selectOption: function(id,value){
+ var options = this.el.find(id + ' select > option');
+ if (options) {
+ options.each(function(opt){
+ if (this.value == value) {
+ $(this).attr('selected','selected');
+ return false;
+ }
+ });
+ }
},
onEditorSubmit: function(e) {
var select = this.el.find('.editor-group select');
- $editor = this;
- var series = this.$series.map(function () {
+ var $editor = this;
+ var $series = this.el.find('.editor-series select');
+ var series = $series.map(function () {
return $(this).val();
});
var updatedState = {
@@ -127,50 +167,59 @@ could be simpler just to have a common template! There appear to be issues generating a Flot graph if either:
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
var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]);
if ((!areWeVisible || this.model.currentDocuments.length === 0)) {
+ this.needToRedraw = true;
return;
+ }check we have something to plot
if (this.state.get('group') && this.state.get('series')) {
+ var series = this.createSeries();
+ var options = this.getGraphOptions(this.state.attributes.graphType);
+ this.plot = $.plot(this.$graph, series, options);
+ this.setupTooltips();
}
- var series = this.createSeries();
- var options = this.getGraphOptions(this.state.attributes.graphType);
- this.plot = $.plot(this.$graph, series, options);
- this.setupTooltips();create this.plot and cache it - if (!this.plot) { - this.plot = $.plot(this.$graph, series, options); - } else { - this.plot.parseOptions(options); - this.plot.setData(this.createSeries()); - this.plot.resize(); - this.plot.setupGrid(); - this.plot.draw(); - }
},needs to be function as can depend on state
getGraphOptions: function(typeId) {
- var self = this;special tickformatter to show labels rather than numbers
var tickFormatter = function (val) {
+ },Get options for Flot Graph
+ +needs to be function as can depend on state
+ +@param typeId graphType id (lines, lines-and-points etc)
getGraphOptions: function(typeId) {
+ var self = this;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.currentDocuments.models[val]) {
- var out = self.model.currentDocuments.models[val].get(self.state.attributes.group);if the value was in fact a number we want that not the
if (typeof(out) == 'number') {
+ var out = self.model.currentDocuments.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;
- };TODO: we should really use tickFormatter and 1 interval ticks if (and -only if) x-axis values are non-numeric -However, that is non-trivial to work out from a dataset (datasets may -have no field type info). Thus at present we only do this for bars.
var options = {
+ };
+
+ 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 }
- }
+ series: {
+ lines: { show: true }
+ },
+ xaxis: xaxis
},
points: {
series: {
points: { show: true }
},
+ xaxis: xaxis,
grid: { hoverable: true, clickable: true }
},
'lines-and-points': {
@@ -178,6 +227,7 @@ have no field type info). Thus at present we only do this for bars.
points: { show: true },
lines: { show: true }
},
+ xaxis: xaxis,
grid: { hoverable: true, clickable: true }
},
bars: {
@@ -201,7 +251,7 @@ have no field type info). Thus at present we only do this for bars.
}
}
};
- return options[typeId];
+ return optionsPerGraphType[typeId];
},
setupTooltips: function() {
@@ -227,12 +277,20 @@ have no field type info). Thus at present we only do this for bars.
$("#flot-tooltip").remove();
var x = item.datapoint[0];
- var y = item.datapoint[1];convert back from 'index' value on x-axis (e.g. in cases where non-number values)
if (self.model.currentDocuments.models[x]) {
+ 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.currentDocuments.models[x]) {
x = self.model.currentDocuments.models[x].get(self.state.attributes.group);
} else {
x = x.toFixed(2);
}
- y = y.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,
@@ -256,11 +314,16 @@ have no field type info). Thus at present we only do this for bars.
_.each(this.state.attributes.series, function(field) {
var points = [];
_.each(self.model.currentDocuments.models, function(doc, index) {
- var x = doc.get(self.state.attributes.group);
- var y = doc.get(field);
+ 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 = new Date(x);
+ }
+ var yfield = self.model.fields.get(field);
+ var y = doc.getFieldValue(yfield);
if (typeof x === 'string') {
x = index;
- }horizontal bar chart
if (self.state.attributes.graphType == 'bars') {
+ }horizontal bar chart
if (self.state.attributes.graphType == 'bars') {
points.push([y, x]);
} else {
points.push([x, y]);
@@ -269,45 +332,36 @@ have no field type info). Thus at present we only do this for bars.
series.push({data: points, label: field});
});
return series;
- },Public: Adds a new empty series select box to the editor.
+ },Public: Adds a new empty series select box to the editor.
-All but the first select box will have a remove button that allows them -to be removed.
+@param [int] idx index of this series in the list of series
-Returns itself.
addSeries: function (e) {
- e.preventDefault();
- var element = this.$seriesClone.clone(),
- label = element.find('label'),
- index = this.$series.length;
+Returns itself.
addSeries: function (idx) {
+ var data = _.extend({
+ seriesIndex: idx,
+ seriesName: String.fromCharCode(idx + 64 + 1),
+ }, this.model.toTemplateJSON());
- this.el.find('.editor-series-group').append(element);
- this._updateSeries();
- label.append(' [<a href="#remove" class="action-remove-series">Remove</a>]');
- label.find('span').text(String.fromCharCode(this.$series.length + 64));
+ var htmls = $.mustache(this.templateSeriesEditor, data);
+ this.el.find('.editor-series-group').append(htmls);
return this;
- },Public: Removes a series list item from the editor.
+ }, + + _onAddSeries: function(e) { + e.preventDefault(); + this.addSeries(this.state.get('series').length); + },Public: Removes a series list item from the editor.
Also updates the labels of the remaining series elements.
removeSeries: function (e) {
e.preventDefault();
var $el = $(e.target);
$el.parent().parent().remove();
- this._updateSeries();
- this.$series.each(function (index) {
- if (index > 0) {
- var labelSpan = $(this).prev().find('span');
- labelSpan.text(String.fromCharCode(index + 65));
- }
- });
this.onEditorSubmit();
},
toggleHelp: function() {
this.el.find('.editor-info').toggleClass('editor-hide-info');
- },Private: Resets the series property to reference the select elements.
- -Returns itself.
_updateSeries: function () {
- this.$series = this.el.find('.editor-series select');
- }
+ },
});
})(jQuery, recline.View);
diff --git a/docs/view-grid.html b/docs/view-grid.html
index 7999d978..2c57e2cc 100644
--- a/docs/view-grid.html
+++ b/docs/view-grid.html
@@ -31,16 +31,7 @@
'click .row-header-menu': 'onRowHeaderClick',
'click .root-header-menu': 'onRootHeaderClick',
'click .data-table-menu li a': 'onMenuClick'
- },TODO: delete or re-enable (currently this code is not used from anywhere except deprecated or disabled methods (see above)). -showDialog: function(template, data) { - if (!data) data = {}; - util.show('dialog'); - util.render(template, 'dialog-content', data); - util.observeExit($('.dialog-content'), function() { - util.hide('dialog'); - }) - $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); -},
====================================================== + },
====================================================== Column and row menus
onColumnHeaderClick: function(e) {
this.tempState.currentColumn = $(e.target).closest('.column-header').attr('data-field');
},
@@ -72,20 +63,20 @@ Column and row menus important this is == as the currentRow will be string (as comes + var self = this; + var doc = _.find(self.model.currentDocuments.models, function(doc) {
important this is == as the currentRow will be string (as comes from DOM) while id may be int
return doc.id == self.tempState.currentRow;
});
doc.destroy().then(function() {
self.model.currentDocuments.remove(doc);
- my.notify("Row deleted successfully");
+ self.trigger('recline:flash', {message: "Row deleted successfully"});
}).fail(function(err) {
- my.notify("Errorz! " + err);
+ self.trigger('recline:flash', {message: "Errorz! " + err});
});
}
};
@@ -93,33 +84,16 @@ from DOM) while id may be int pass the flash message up the chain
view.bind('recline:flash', function(flash) {
+ self.trigger('recline:flash', flash);
});
view.state = this.tempState;
view.render();
- $el.empty();
- $el.append(view.el);
- util.observeExit($el, function() {
- util.hide('dialog');
- });
- $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' });
- },
-
- showTransformDialog: function() {
- var $el = $('.dialog-content');
- util.show('dialog');
- var view = new recline.View.DataTransform({
- });
- view.render();
- $el.empty();
- $el.append(view.el);
- util.observeExit($el, function() {
- util.hide('dialog');
- });
- $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' });
+ this.el.append(view.el);
+ view.el.modal();
},
setColumnSort: function(order) {
@@ -131,7 +105,7 @@ from DOM) while id may be int change event not being triggered (because it is an array?) so trigger manually
this.state.trigger('change');
this.render();
},
@@ -139,7 +113,7 @@ from DOM) while id may be int ======================================================
+ },======================================================
template: ' \
<table class="recline-grid table-striped table-condensed" cellspacing="0"> \
@@ -183,7 +157,7 @@ from DOM) while id may be int TODO: move this sort of thing into a toTemplateJSON method on Dataset?
modelData.fields = _.map(this.fields, function(field) { return field.toJSON(); });
+ modelData.notEmpty = ( this.fields.length > 0 );TODO: move this sort of thing into a toTemplateJSON method on Dataset?
modelData.fields = _.map(this.fields, function(field) { return field.toJSON(); });
return modelData;
},
render: function() {
@@ -203,10 +177,10 @@ from DOM) while id may be int Since we want this to update in place it is up to creator to provider the element to attach to.
@@ -269,8 +243,20 @@ var row = new GridRow({ var html = $.mustache(this.template, this.toTemplateJSON()); $(this.el).html(html); return this; - },=================== -Cell Editor methods
onEditClick: function(e) {
+ },=================== +Cell Editor methods
cellEditorTemplate: ' \
+ <div class="menu-container data-table-cell-editor"> \
+ <textarea class="data-table-cell-editor-editor" bind="textarea">{{value}}</textarea> \
+ <div id="data-table-cell-editor-actions"> \
+ <div class="data-table-cell-editor-action"> \
+ <button class="okButton btn primary">Update</button> \
+ <button class="cancelButton btn danger">Cancel</button> \
+ </div> \
+ </div> \
+ </div> \
+ ',
+
+ onEditClick: function(e) {
var editing = this.el.find('.data-table-cell-editor-editor');
if (editing.length > 0) {
editing.parents('.data-table-cell-value').html(editing.text()).siblings('.data-table-cell-edit').removeClass("hidden");
@@ -278,10 +264,12 @@ Cell Editor methods
{
- // geomField if specified will be used in preference to lat/lon
+ // geomField if specified will be used in preference to lat/lon
geomField: {id of field containing geometry in the dataset}
lonField: {id of field containing longitude in the dataset}
latField: {id of field containing latitude in the dataset}
@@ -72,6 +72,11 @@ have the following (optional) configuration options:
<div class="editor-buttons"> \
<button class="btn editor-update-map">Update</button> \
</div> \
+ <div class="editor-options" > \
+ <label class="checkbox"> \
+ <input type="checkbox" id="editor-auto-zoom" checked="checked" /> \
+ Auto zoom to features</label> \
+ </div> \
<input type="hidden" class="editor-id" value="map-1" /> \
</div> \
</form> \
@@ -83,7 +88,8 @@ If not found, the user will need to define the fields via the editor.
longitudeFieldNames: ['lon','longitude'],
geometryFieldNames: ['geom','the_geom','geometry','spatial','location'], Define here events for UI elements
events: {
'click .editor-update-map': 'onEditorSubmit',
- 'change .editor-field-type': 'onFieldTypeChange'
+ 'change .editor-field-type': 'onFieldTypeChange',
+ 'change #editor-auto-zoom': 'onAutoZoomChange'
},
initialize: function(options) {
@@ -96,12 +102,25 @@ If not found, the user will need to define the fields via the editor.
self._setupGeometryField()
self.render()
});Listen to changes in the documents
this.model.currentDocuments.bind('add', function(doc){self.redraw('add',doc)});
+ this.model.currentDocuments.bind('change', function(doc){
+ self.redraw('remove',doc);
+ self.redraw('add',doc);
+ });
this.model.currentDocuments.bind('remove', function(doc){self.redraw('remove',doc)});
- this.model.currentDocuments.bind('reset', function(){self.redraw('reset')});If the div was hidden, Leaflet needs to recalculate some sizes -to display properly
this.bind('view:show',function(){
- if (self.map) {
- self.map.invalidateSize();
+ this.model.currentDocuments.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.autoZoom) {
+ self._zoomToFeatures();
+ self._zoomPending = false;
}
+ }
+ self.visible = true;
+ });
+ this.bind('view:hide',function(){
+ self.visible = false;
});
var stateData = _.extend({
@@ -113,6 +132,7 @@ to display properly Public: Adds the necessary elements to the page.
@@ -173,6 +193,13 @@ to display properlyUI Event handlers
Public: Update map with user options
@@ -205,6 +232,10 @@ type selected.Private: Add one or n features to the map
For each document passed, a GeoJSON geometry will be extracted and added @@ -212,7 +243,6 @@ to the features layer. If an exception is thrown, the process will be stopped and an error notification shown.
Each feature will have a popup associated with all the document fields.
_add: function(docs){
-
var self = this;
if (!(docs instanceof Array)) docs = [docs];
@@ -226,7 +256,9 @@ stopped and an error notification shown.
} else if (feature instanceof Object){Build popup contents TODO: mustache?
html = ''
for (key in doc.attributes){
- html += '<div><strong>' + key + '</strong>: '+ doc.attributes[key] + '</div>'
+ if (!(self.state.get('geomField') && key == self.state.get('geomField'))){
+ html += '<div><strong>' + key + '</strong>: '+ doc.attributes[key] + '</div>';
+ }
}
feature.properties = {popupContent: html};Add a reference to the model id, which will allow us to link this Leaflet layer to a Recline doc
feature.properties.cid = doc.cid;
@@ -238,13 +270,13 @@ link this Leaflet layer to a Recline doc Private: Return a GeoJSON geomtry extracted from the document fields
_getGeometryFromDocument: function(doc){
if (this.geomReady){
- if (this.state.get('geomField')){We assume that the contents of the field are a valid GeoJSON object
return doc.attributes[this.state.get('geomField')];
- } else if (this.state.get('lonField') && this.state.get('latField')){We'll create a GeoJSON like point object from the two lat/lon fields
var lon = doc.get(this.state.get('lonField'));
+ if (this.state.get('geomField')){
+ var value = doc.get(this.state.get('geomField'));
+ if (typeof(value) === 'string'){We have a GeoJSON string representation
return $.parseJSON(value);
+ } else {We assume that the contents of the field are a valid GeoJSON object
return value;
+ }
+ } else if (this.state.get('lonField') && this.state.get('latField')){We'll create a GeoJSON like point object from the two lat/lon fields
var lon = doc.get(this.state.get('lonField'));
var lat = doc.get(this.state.get('latField'));
- if (lon && lat) {
+ if (!isNaN(parseFloat(lon)) && !isNaN(parseFloat(lat))) {
return {
type: 'Point',
- coordinates: [
- doc.attributes[this.state.get('lonField')],
- doc.attributes[this.state.get('latField')]
- ]
+ coordinates: [lon,lat]
};
}
}
return null;
}
- },Private: Check if there is a field with GeoJSON geometries or alternatively, + },
Private: Check if there is a field with GeoJSON geometries or alternatively, two fields with lat/lon values.
If not found, the user can define them via the UI form.
_setupGeometryField: function(){
var geomField, latField, lonField;
- this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));should not overwrite if we have already set this (e.g. explicitly via state)
if (!this.geomReady) {
+ this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));should not overwrite if we have already set this (e.g. explicitly via state)
if (!this.geomReady) {
this.state.set({
geomField: this._checkField(this.geometryFieldNames),
latField: this._checkField(this.latitudeFieldNames),
@@ -293,7 +326,7 @@ two fields with lat/lon values.
});
this.geomReady = (this.state.get('geomField') || (this.state.get('latField') && this.state.get('lonField')));
}
- },Private: Check if a field in the current model exists in the provided + },
Private: Check if a field in the current model exists in the provided list of names.
_checkField: function(fieldNames){
var field;
var modelFieldNames = this.model.fields.pluck('id');
@@ -304,7 +337,15 @@ list of names. Private: Sets up the Leaflet map control and the features layer.
+ },Private: Zoom to map to current features extent if any, or to the full +extent if none.
_zoomToFeatures: function(){
+ var bounds = this.features.getBounds();
+ if (bounds){
+ this.map.fitBounds(bounds);
+ } else {
+ this.map.setView(new L.LatLng(0, 0), 2);
+ }
+ },Private: Sets up the Leaflet map control and the features layer.
The map uses a base layer from MapQuest based on OpenStreetMap.
_setupMap: function(){
@@ -325,13 +366,28 @@ on OpenStreetMap. This will be available in the next Leaflet stable release. +In the meantime we add it manually to our layer.
this.features.getBounds = function(){
+ var bounds = new L.LatLngBounds();
+ this._iterateLayers(function (layer) {
+ if (layer instanceof L.Marker){
+ bounds.extend(layer.getLatLng());
+ } else {
+ if (layer.getBounds){
+ bounds.extend(layer.getBounds().getNorthEast());
+ bounds.extend(layer.getBounds().getSouthWest());
+ }
+ }
+ }, this);
+ return (typeof bounds.getNorthEast() !== 'undefined') ? bounds : null;
+ }
+
this.map.addLayer(this.features);
this.map.setView(new L.LatLng(0, 0), 2);
this.mapReady = true;
- },Private: Helper function to select an option from a select list
_selectOption: function(id,value){
+ },Private: Helper function to select an option from a select list
_selectOption: function(id,value){
var options = $('.' + id + ' > select > option');
if (options){
options.each(function(opt){
diff --git a/docs/view.html b/docs/view.html
index 34f9cb34..dcb33dd2 100644
--- a/docs/view.html
+++ b/docs/view.html
@@ -1,9 +1,10 @@
view.js Jump To … view.js
/*jshint multistr:true */ Recline Views
-Recline Views are Backbone Views and in keeping with normal Backbone views
-are Widgets / Components displaying something in the DOM. Like all Backbone
-views they have a pointer to a model or a collection and is bound to an
-element.
+Recline Views are instances of Backbone Views and they act as 'WUI' (web
+user interface) component displaying some model object in the DOM. Like all
+Backbone views they have a pointer to a model (or a collection) and have an
+associated DOM-style element (usually this element will be bound into the
+page at some point).
Views provided by core Recline are crudely divided into two types:
@@ -165,12 +166,6 @@ initialized the DataExplorer with the relevant views themselves.
<div class="clearfix"></div> \
</div> \
<div class="data-view-container"></div> \
- <div class="dialog-overlay" style="display: none; z-index: 101; "> </div> \
- <div class="dialog ui-draggable" style="display: none; z-index: 102; top: 101px; "> \
- <div class="dialog-frame" style="width: 700px; visibility: visible; "> \
- <div class="dialog-content dialog-border"></div> \
- </div> \
- </div> \
</div> \
',
events: {
@@ -207,7 +202,8 @@ initialized the DataExplorer with the relevant views themselves.
}),
}];
} these must be called after pageViews are created
this.render();
- this._bindStateChanges();
now do updates based on state (need to come after render)
if (this.state.get('readOnly')) {
+ this._bindStateChanges();
+ this._bindFlashNotifications();
now do updates based on state (need to come after render)
if (this.state.get('readOnly')) {
this.setReadOnly();
}
if (this.state.get('currentView')) {
@@ -216,20 +212,16 @@ initialized the DataExplorer with the relevant views themselves.
this.updateNav(this.pageViews[0].id);
}
- this.router = new Backbone.Router();
- this.setupRouting();
-
this.model.bind('query:start', function() {
- my.notify('Loading data', {loader: true});
+ self.notify({message: 'Loading data', loader: true});
});
this.model.bind('query:done', function() {
- my.clearNotifications();
+ self.clearNotifications();
self.el.find('.doc-count').text(self.model.docCount || 'Unknown');
- my.notify('Data loaded', {category: 'success'});
update navigation
var qs = my.parseHashQueryString();
- qs.reclineQuery = JSON.stringify(self.model.queryState.toJSON());
- var out = my.getNewHashForQueryString(qs);
self.router.navigate(out);
});
+ self.notify({message: 'Data loaded', category: 'success'});
+ });
this.model.bind('query:fail', function(error) {
- my.clearNotifications();
+ self.clearNotifications();
var msg = '';
if (typeof(error) == 'string') {
msg = error;
@@ -243,14 +235,14 @@ initialized the DataExplorer with the relevant views themselves.
} else {
msg = 'There was an error querying the backend';
}
- my.notify(msg, {category: 'error', persist: true});
- });
retrieve basic data like fields etc
+ self.notify({message: msg, category: 'error', persist: true});
+ });
retrieve basic data like fields etc
note this.model and dataset returned are the same
this.model.fetch()
.done(function(dataset) {
self.model.query(self.state.get('query'));
})
.fail(function(error) {
- my.notify(error.message, {category: 'error', persist: true});
+ self.notify({message: error.message, category: 'error', persist: true});
});
},
@@ -283,25 +275,12 @@ note this.model and dataset returned are the same
this.el.find('.header').append(facetViewer.el);
},
- setupRouting: function() {
- var self = this; Default route
- this.router.route(/^(\?.)?$/, this.pageViews[0].id, function(queryString) {
- self.updateNav(self.pageViews[0].id, queryString);
- });
- $.each(this.pageViews, function(idx, view) {
- self.router.route(/^([^?]+)(\?.)?/, 'view', function(viewId, queryString) {
- self.updateNav(viewId, queryString);
- });
- });
this.router.route(/.*/, 'view', function() {
- });
- },
-
updateNav: function(pageName) {
this.el.find('.navigation li').removeClass('active');
this.el.find('.navigation li a').removeClass('disabled');
var $el = this.el.find('.navigation li a[data-view="' + pageName + '"]');
$el.parent().addClass('active');
- $el.addClass('disabled');
show the specific page
_.each(this.pageViews, function(view, idx) {
+ $el.addClass('disabled');
show the specific page
_.each(this.pageViews, function(view, idx) {
if (view.id === pageName) {
view.view.el.show();
view.view.trigger('view:show');
@@ -327,15 +306,15 @@ note this.model and dataset returned are the same
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 = recline.Util.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__,
@@ -348,7 +327,7 @@ note this.model and dataset returned are the same
},
_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) {
@@ -358,11 +337,58 @@ note this.model and dataset returned are the same
self.state.set(update);
pageView.view.state.bind('change', function() {
var update = {};
- update['view-' + pageView.id] = pageView.view.state.toJSON();
- self.state.set(update);
+ update['view-' + pageView.id] = pageView.view.state.toJSON(); had problems where change not being triggered for e.g. grid view so let's do it explicitly
self.state.set(update, {silent: true});
+ self.state.trigger('change');
});
}
});
+ },
+
+ _bindFlashNotifications: function() {
+ var self = this;
+ _.each(this.pageViews, function(pageView) {
+ pageView.view.bind('recline:flash', function(flash) {
+ self.notify(flash);
+ });
+ });
+ },
notify
+
+Create a notification (a div.alert in div.alert-messsages) using provided
+flash object. Flash attributes (all are optional):
+
+
+- message: message to show.
+- category: warning (default), success, error
+- persist: if true alert is persistent, o/w hidden after 3s (default = false)
+- loader: if true show loading spinner
+
notify: function(flash) {
+ var tmplData = _.extend({
+ message: '',
+ category: 'warning'
+ },
+ flash
+ );
+ var _template = ' \
+ <div class="alert alert-{{category}} fade in" data-alert="alert"><a class="close" data-dismiss="alert" href="#">×</a> \
+ {{message}} \
+ {{#loader}} \
+ <span class="notification-loader"> </span> \
+ {{/loader}} \
+ </div>';
+ var _templated = $.mustache(_template, tmplData);
+ _templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages'));
+ if (!flash.persist) {
+ setTimeout(function() {
+ $(_templated).fadeOut(1000, function() {
+ $(this).remove();
+ });
+ }, 1000);
+ }
+ },
clearNotifications
+
+Clear all existing notifications
clearNotifications: function() {
+ var $notifications = $('.recline-data-explorer .alert-messages .alert');
+ $notifications.remove();
}
});
DataExplorer.restore
@@ -594,95 +620,6 @@ note this.model and dataset returned are the same
}
});
-/* ========================================================== */ Miscellaneous Utilities
var urlPathRegex = /^([^?]+)(\?.*)?/;
Parse the Hash section of a URL into path and query string
my.parseHashUrl = function(hashUrl) {
- var parsed = urlPathRegex.exec(hashUrl);
- if (parsed === null) {
- return {};
- } else {
- return {
- path: parsed[1],
- query: parsed[2] || ''
- };
- }
-};
Parse a URL query string (?xyz=abc...) into a dictionary.
my.parseQueryString = function(q) {
- if (!q) {
- return {};
- }
- var urlParams = {},
- e, d = function (s) {
- return unescape(s.replace(/\+/g, " "));
- },
- r = /([^&=]+)=?([^&]*)/g;
-
- if (q && q.length && q[0] === '?') {
- q = q.slice(1);
- }
- while (e = r.exec(q)) {
TODO: have values be array as query string allow repetition of keys
urlParams[d(e[1])] = d(e[2]);
- }
- return urlParams;
-};
Parse the query string out of the URL hash
my.parseHashQueryString = function() {
- q = my.parseHashUrl(window.location.hash).query;
- return my.parseQueryString(q);
-};
Compse a Query String
my.composeQueryString = function(queryParams) {
- var queryString = '?';
- var items = [];
- $.each(queryParams, function(key, value) {
- if (typeof(value) === 'object') {
- value = JSON.stringify(value);
- }
- items.push(key + '=' + value);
- });
- queryString += items.join('&');
- return queryString;
-};
-
-my.getNewHashForQueryString = function(queryParams) {
- var queryPart = my.composeQueryString(queryParams);
- if (window.location.hash) {
slice(1) to remove # at start
return window.location.hash.split('?')[0].slice(1) + queryPart;
- } else {
- return queryPart;
- }
-};
-
-my.setHashQueryString = function(queryParams) {
- window.location.hash = my.getNewHashForQueryString(queryParams);
-};
notify
-
-Create a notification (a div.alert in div.alert-messsages) using provide messages and options. Options are:
-
-
-- category: warning (default), success, error
-- persist: if true alert is persistent, o/w hidden after 3s (default = false)
-- loader: if true show loading spinner
-
my.notify = function(message, options) {
- if (!options) options = {};
- var tmplData = _.extend({
- msg: message,
- category: 'warning'
- },
- options);
- var _template = ' \
- <div class="alert alert-{{category}} fade in" data-alert="alert"><a class="close" data-dismiss="alert" href="#">×</a> \
- {{msg}} \
- {{#loader}} \
- <span class="notification-loader"> </span> \
- {{/loader}} \
- </div>';
- var _templated = $.mustache(_template, tmplData);
- _templated = $(_templated).appendTo($('.recline-data-explorer .alert-messages'));
- if (!options.persist) {
- setTimeout(function() {
- $(_templated).fadeOut(1000, function() {
- $(this).remove();
- });
- }, 1000);
- }
-};
clearNotifications
-
-Clear all existing notifications
my.clearNotifications = function() {
- var $notifications = $('.recline-data-explorer .alert-messages .alert');
- $notifications.remove();
-};
})(jQuery, recline.View);
diff --git a/download.markdown b/download.markdown
new file mode 100644
index 00000000..e60f0e95
--- /dev/null
+++ b/download.markdown
@@ -0,0 +1,53 @@
+---
+layout: container
+title: Download
+---
+
+
+
+ Download Recline
+
+
+
+Besides the library itself, the download package contains full source code,
+unit tests, files for debugging and a build system. The production files
+(included the same way as in the code above) are in the dist folder.
+
+Download Recline v0.5 (master) (in-progress version)
+
+Just want the all-in-one file containing all of Recline library in a single file? Here it is:
+
+recline.js all-in-one (master)
+
+[View Changelog](https://github.com/okfn/recline#changelog)
+
+### Dependencies
+
+Recline has dependencies on some third-party libraries, notably JQuery and Backbone:
+
+* [JQuery](http://jquery.com/) >= 1.6
+* [Backbone](http://backbonejs.org/) >= 0.5.1
+* [Underscore](http://documentcloud.github.com/underscore/) >= 1.0
+
+Optional dependencies:
+
+* JQuery Mustache (required for all views)
+* [JQuery Flot](http://code.google.com/p/flot/) >= 0.7 (required for for graph view)
+* [Leaflet](http://leaflet.cloudmade.com/) >= 0.3.1 (required for map view
+* [Bootstrap](http://twitter.github.com/bootstrap/) >= v2.0 (default option for CSS and UI JS but you can use your own)
+
+### Example
+
+Here is an example of the page setup for an app using every Recline component:
+
+{% highlight html %}
+
+
+
+
+
+{% include recline-deps.html %}
+{% endhighlight %}
+
diff --git a/example-quickstart.markdown b/example-quickstart.markdown
new file mode 100644
index 00000000..e87ab283
--- /dev/null
+++ b/example-quickstart.markdown
@@ -0,0 +1,98 @@
+---
+layout: container
+title: Library - Example - Quickstart
+recline-deps: true
+---
+
+
+
+ Recline Quickstart Guide
+
+
+
+This step-by-step guide will quickly get you started with Recline basics, including creating a dataset from local data and setting up a data grid to display this data.
+
+### Preparing your page
+
+Before writing any code with Recline, you need to do the following preparation steps on your page:
+
+1. [Download ReclineJS](download.html) and relevant dependencies.
+2. Include the relevant CSS in the head section of your document:
+ {% highlight html %}
+
+
+
+ {% endhighlight %}
+
+3. Include the relevant Javascript files somewhere on the page (preferably before body close tag):
+ {% highlight html %}
+
+
+
+
+
+
+
+
+{% endhighlight %}
+
+4. Create a div to hold the Recline view(s):
+ {% highlight html %}
+ {% endhighlight %}
+
+You're now ready to start working with Recline.
+
+### Creating a Dataset
+
+We are going to be working with the following set of data:
+
+{% highlight javascript %}
+var data = [
+ {id: 0, x: 1, y: 2, z: 3, country: 'UK', label: 'first'},
+ {id: 1, x: 2, y: 4, z: 6, country: 'UK', label: 'second'},
+ {id: 2, x: 3, y: 6, z: 9, country: 'US', label: 'third'}
+ ];
+{% endhighlight %}
+
+Here we have 3 documents / rows each of which is a javascript object containing keys and values (note that all values here are 'simple' but there is no reason you cannot have full objects as values.
+
+We can now create a recline Dataset object (and memory backend) from this raw data:
+
+{% highlight javascript %}
+var dataset = recline.Backend.createDataset(data);
+{% endhighlight %}
+
+Note that behind the scenes Recline will create a Memory backend for this dataset as in Recline every dataset object must have a backend from which it can push and pull data. In the case of in-memory data this is a little artificial since all the data is available locally but this makes more sense for situations where one is connecting to a remote data source (and one which may contain a lot of data).
+
+
+### Setting up the Grid
+
+Let's create a data grid view to display the dataset we have just created, binding the view to the `` we created earlier:
+
+{% highlight javascript %}
+var grid = new recline.View.Grid({
+ model: dataset,
+ el: $('#recline-grid')
+});
+grid.render();
+{% endhighlight %}
+
+And hey presto:
+
+
+
+
+
diff --git a/library.html b/library.html
index e771e7ab..7165f566 100644
--- a/library.html
+++ b/library.html
@@ -3,76 +3,66 @@ layout: default
title: Library - Home
---
-
+
- The Data Library
+ Recline Library and Data Components
-
-
- Examples
-
- Note: A quick read through of the Concepts section will
- likely be useful in understanding the details of the examples.
-
- Note: for all the following examples you should have
- included relevant Recline dependencies.
-
- Simple in-memory dataset.
-
- // Some data you have
- // Your data must be in the form of list of documents / rows
- // Each document/row is an Object with keys and values
- var data = [
- {id: 0, x: 1, y: 2, z: 3, country: 'UK', label: 'first'}
- , {id: 1, x: 2, y: 4, z: 6, country: 'UK', label: 'second'}
- , {id: 2, x: 3, y: 6, z: 9, country: 'US', label: 'third'}
- ];
-
- // Create a Dataset object from local in-memory data
- // Dataset object is a Backbone model - more info on attributes in model docs below
- var dataset = recline.Backend.createDataset(data);
-
- // Now create the main explorer view (it will create other views as needed)
- // DataExplorer is a Backbone View
- var explorer = recline.View.DataExplorer({
- model: dataset,
- // you can specify any element to bind to in the dom
- el: $('.data-explorer-here')
- });
- // Start Backbone routing (if you want routing support)
- Backbone.history.start();
-
-
- Creating a Dataset Explicitly with a Backend
-
- // Connect to ElasticSearch index/type as our data source
- // There are many other backends you can use (and you can write your own)
- var backend = new recline.Backend.ElasticSearch();
-
- // Dataset is a Backbone model so the first hash become model attributes
- var dataset = recline.Model.Dataset({
- id: 'my-id',
- // url for source of this dataset - will be used by backend
- url: 'http://localhost:9200/my-index/my-type',
- // any other metadata e.g.
- title: 'My Dataset Title'
- },
- backend
- );
-
-
+
+ Building on Backbone, Recline
+ supplies components and structure to data-heavy applications by providing a
+ set of models (Dataset, Document/Row, Field) and views (Grid, Map, Graph
+ etc).
+
+ Examples
+ Note: A quick read through of the Concepts section will
+ likely be useful in understanding the details of the examples.
+
+
+
+
+ Loading from difference sources: Google Docs, Local CSV, DataHub
+
+
+ Twitter Example
+
+
+
+
+ Customing Display and Import using Fields
+
+
+ Listening to events
+
+
+ Setting and Getting State
+
+
+
+ Extending Recline
+
+
+ Create a new View
+
+
+ Create a new Backend
+
+
+ Create a Custom Document Object
+
+
+
- Concepts and Structure
+ Concepts and Structure
-
Recline has a simple structure layered on top of the basic Model/View
distinction inherent in Backbone.
diff --git a/recline.js b/recline.js
index 271e9c54..261de327 100644
--- a/recline.js
+++ b/recline.js
@@ -556,157 +556,83 @@ my.backends = {};
/*jshint multistr:true */
-var util = function() {
- var templates = {
- transformActions: '- Global transform...
',
- cellEditor: ' \
- \
- \
- \
- \
- \
- \
- \
- \
- \
- ',
- editPreview: ' \
- \
- \
- \
- \
- \
- before \
- \
- \
- after \
- \
- \
- \
- \
- {{#rows}} \
- \
- \
- {{before}} \
- \
- \
- {{after}} \
- \
- \
- {{/rows}} \
- \
-
\
- \
- '
- };
+this.recline = this.recline || {};
+this.recline.Util = this.recline.Util || {};
- $.fn.serializeObject = function() {
- var o = {};
- var a = this.serializeArray();
- $.each(a, function() {
- if (o[this.name]) {
- if (!o[this.name].push) {
- o[this.name] = [o[this.name]];
- }
- o[this.name].push(this.value || '');
- } else {
- o[this.name] = this.value || '';
- }
- });
- return o;
- };
+(function(my) {
+// ## Miscellaneous Utilities
- function registerEmitter() {
- var Emitter = function(obj) {
- this.emit = function(obj, channel) {
- if (!channel) channel = 'data';
- this.trigger(channel, obj);
- };
+var urlPathRegex = /^([^?]+)(\?.*)?/;
+
+// Parse the Hash section of a URL into path and query string
+my.parseHashUrl = function(hashUrl) {
+ var parsed = urlPathRegex.exec(hashUrl);
+ if (parsed === null) {
+ return {};
+ } else {
+ return {
+ path: parsed[1],
+ query: parsed[2] || ''
};
- MicroEvent.mixin(Emitter);
- return new Emitter();
- }
-
- function listenFor(keys) {
- var shortcuts = { // from jquery.hotkeys.js
- 8: "backspace", 9: "tab", 13: "return", 16: "shift", 17: "ctrl", 18: "alt", 19: "pause",
- 20: "capslock", 27: "esc", 32: "space", 33: "pageup", 34: "pagedown", 35: "end", 36: "home",
- 37: "left", 38: "up", 39: "right", 40: "down", 45: "insert", 46: "del",
- 96: "0", 97: "1", 98: "2", 99: "3", 100: "4", 101: "5", 102: "6", 103: "7",
- 104: "8", 105: "9", 106: "*", 107: "+", 109: "-", 110: ".", 111 : "/",
- 112: "f1", 113: "f2", 114: "f3", 115: "f4", 116: "f5", 117: "f6", 118: "f7", 119: "f8",
- 120: "f9", 121: "f10", 122: "f11", 123: "f12", 144: "numlock", 145: "scroll", 191: "/", 224: "meta"
- };
- window.addEventListener("keyup", function(e) {
- var pressed = shortcuts[e.keyCode];
- if(_.include(keys, pressed)) app.emitter.emit("keyup", pressed);
- }, false);
- }
-
- function observeExit(elem, callback) {
- var cancelButton = elem.find('.cancelButton');
- // TODO: remove (commented out as part of Backbon-i-fication
- // app.emitter.on('esc', function() {
- // cancelButton.click();
- // app.emitter.clear('esc');
- // });
- cancelButton.click(callback);
- }
-
- function show( thing ) {
- $('.' + thing ).show();
- $('.' + thing + '-overlay').show();
}
+};
- function hide( thing ) {
- $('.' + thing ).hide();
- $('.' + thing + '-overlay').hide();
- // TODO: remove or replace (commented out as part of Backbon-i-fication
- // if (thing === "dialog") app.emitter.clear('esc'); // todo more elegant solution
- }
-
- function position( thing, elem, offset ) {
- var position = $(elem.target).position();
- if (offset) {
- if (offset.top) position.top += offset.top;
- if (offset.left) position.left += offset.left;
- }
- $('.' + thing + '-overlay').show().click(function(e) {
- $(e.target).hide();
- $('.' + thing).hide();
- });
- $('.' + thing).show().css({top: position.top + $(elem.target).height(), left: position.left});
+// Parse a URL query string (?xyz=abc...) into a dictionary.
+my.parseQueryString = function(q) {
+ if (!q) {
+ return {};
}
+ var urlParams = {},
+ e, d = function (s) {
+ return unescape(s.replace(/\+/g, " "));
+ },
+ r = /([^&=]+)=?([^&]*)/g;
- function render( template, target, options ) {
- if ( !options ) options = {data: {}};
- if ( !options.data ) options = {data: options};
- var html = $.mustache( templates[template], options.data );
- var targetDom = null;
- if (target instanceof jQuery) {
- targetDom = target;
- } else {
- targetDom = $( "." + target + ":first" );
- }
- if( options.append ) {
- targetDom.append( html );
- } else {
- targetDom.html( html );
- }
- // TODO: remove (commented out as part of Backbon-i-fication
- // if (template in app.after) app.after[template]();
+ if (q && q.length && q[0] === '?') {
+ q = q.slice(1);
}
+ while (e = r.exec(q)) {
+ // TODO: have values be array as query string allow repetition of keys
+ urlParams[d(e[1])] = d(e[2]);
+ }
+ return urlParams;
+};
+
+// Parse the query string out of the URL hash
+my.parseHashQueryString = function() {
+ q = my.parseHashUrl(window.location.hash).query;
+ return my.parseQueryString(q);
+};
+
+// Compse a Query String
+my.composeQueryString = function(queryParams) {
+ var queryString = '?';
+ var items = [];
+ $.each(queryParams, function(key, value) {
+ if (typeof(value) === 'object') {
+ value = JSON.stringify(value);
+ }
+ items.push(key + '=' + value);
+ });
+ queryString += items.join('&');
+ return queryString;
+};
+
+my.getNewHashForQueryString = function(queryParams) {
+ var queryPart = my.composeQueryString(queryParams);
+ if (window.location.hash) {
+ // slice(1) to remove # at start
+ return window.location.hash.split('?')[0].slice(1) + queryPart;
+ } else {
+ return queryPart;
+ }
+};
+
+my.setHashQueryString = function(queryParams) {
+ window.location.hash = my.getNewHashForQueryString(queryParams);
+};
+})(this.recline.Util);
- return {
- registerEmitter: registerEmitter,
- listenFor: listenFor,
- show: show,
- hide: hide,
- position: position,
- render: render,
- observeExit: observeExit
- };
-}();
/*jshint multistr:true */
this.recline = this.recline || {};
@@ -791,7 +717,6 @@ my.Graph = Backbone.View.extend({
\
\