diff --git a/app/built.html b/app/built.html index 8f724fd5..6fb0ed16 100644 --- a/app/built.html +++ b/app/built.html @@ -2,7 +2,7 @@ - Recline Data Explorer Demo + Recline Data Explorer (Built Sources) @@ -11,46 +11,160 @@ + + + + + + + + + - - - - - + + + + + + + + + + + - + +
+ + + + + +
+
+ diff --git a/app/index.html b/app/index.html index 63e1d9ef..42666af3 100644 --- a/app/index.html +++ b/app/index.html @@ -32,7 +32,6 @@ - @@ -58,6 +57,8 @@ + +
@@ -122,6 +123,50 @@
+
+
+
+
+

Welcome to the Recline Data Explorer

+

The Data Explorer is an application for exploring + and working with data built in pure javascript and html. In basic operation it's much like a spreadsheet - though it's + feature set is a little different. In particular, the Data + Explorer provides: +

    +
  • Data grid / spreadsheet
  • +
  • Data editing including programmatic data transformation in javascript
  • +
  • Visualizations includes graphs and maps
  • +
  • Import and export from a variety of sources including online sources such as online Excel and CSV files, Google docs and + the DataHub and offline sources like CSV files on your local machine.
  • +
  • Use online or offline - because the app is built in pure javascript and html you can use it anywhere there's a modern web browser. Using offline is as easy and downloading this web page to your local machine.
  • +
+
+
+

View the demo

+

Take a look at a local demo dataset.

+

View the demo dataset »

+
+
+

Read the tutorial

+

Take a look at the tutorial for using the data explorer:

+ Read the tutorial » +
+
+

Import some data

+

Starting working with some data straight away. You can import some data using the menu at the top right of this page.

+
+
+
+
+
+ +
+
+
+
+ + + +
+ +
+ +
+
+
@@ -166,12 +218,6 @@
- -
-
-
-
-
diff --git a/app/js/app.js b/app/js/app.js index b5ef942d..675914c8 100755 --- a/app/js/app.js +++ b/app/js/app.js @@ -2,7 +2,6 @@ jQuery(function($) { var app = new ExplorerApp({ el: $('.recline-app') }) - Backbone.history.start(); }); var ExplorerApp = Backbone.View.extend({ @@ -15,8 +14,14 @@ var ExplorerApp = Backbone.View.extend({ this.el = $(this.el); this.explorer = null; this.explorerDiv = $('.data-explorer-here'); + _.bindAll(this, 'viewExplorer', 'viewHome'); - var state = recline.View.parseQueryString(window.location.search); + this.router = new Backbone.Router(); + this.router.route('', 'home', this.viewHome); + this.router.route(/explorer/, 'explorer', this.viewExplorer); + Backbone.history.start(); + + var state = recline.Util.parseQueryString(window.location.search); if (state) { _.each(state, function(value, key) { try { @@ -30,14 +35,34 @@ var ExplorerApp = Backbone.View.extend({ } } var dataset = null; - if (state.dataset || state.url) { - dataset = recline.Model.Dataset.restore(state); - } else { + // special cases for demo / memory dataset + if (state.url === 'demo' || state.backend === 'memory') { dataset = localDataset(); } - this.createExplorer(dataset, state); + else if (state.dataset || state.url) { + dataset = recline.Model.Dataset.restore(state); + } + if (dataset) { + this.createExplorer(dataset, state); + } }, + viewHome: function() { + this.switchView('home'); + }, + + viewExplorer: function() { + this.router.navigate('explorer'); + this.switchView('explorer'); + }, + + switchView: function(path) { + $('.backbone-page').hide(); + var cssClass = path.replace('/', '-'); + $('.page-' + cssClass).show(); + }, + + // make Explorer creation / initialization in a function so we can call it // again and again createExplorer: function(dataset, state) { @@ -59,12 +84,7 @@ var ExplorerApp = Backbone.View.extend({ this._setupPermaLink(this.dataExplorer); this._setupEmbed(this.dataExplorer); - // HACK (a bit). Issue is that Backbone will not trigger the route - // if you are already at that location so we have to make sure we genuinely switch - if (reload) { - // this.dataExplorer.router.navigate('graph'); - // this.dataExplorer.router.navigate('', true); - } + this.viewExplorer(); }, _setupPermaLink: function(explorer) { @@ -92,7 +112,7 @@ var ExplorerApp = Backbone.View.extend({ }, makePermaLink: function(state) { - var qs = recline.View.composeQueryString(state.toJSON()); + var qs = recline.Util.composeQueryString(state.toJSON()); return window.location.origin + window.location.pathname + qs; }, @@ -128,6 +148,7 @@ var ExplorerApp = Backbone.View.extend({ var file = $file.files[0]; var options = { separator : $form.find('input[name="separator"]').val(), + delimiter : $form.find('input[name="delimiter"]').val(), encoding : $form.find('input[name="encoding"]').val() }; recline.Backend.loadFromCSVFile(file, function(dataset) { @@ -140,26 +161,7 @@ var ExplorerApp = Backbone.View.extend({ // provide a demonstration in memory dataset function localDataset() { - var datasetId = 'test-dataset'; - var inData = { - metadata: { - title: 'My Test Dataset' - , name: '1-my-test-dataset' - , id: datasetId - }, - fields: [{id: 'x'}, {id: 'y'}, {id: 'z'}, {id: 'country'}, {id: 'label'},{id: 'lat'},{id: 'lon'}], - documents: [ - {id: 0, x: 1, y: 2, z: 3, country: 'DE', label: 'first', lat:52.56, lon:13.40} - , {id: 1, x: 2, y: 4, z: 6, country: 'UK', label: 'second', lat:54.97, lon:-1.60} - , {id: 2, x: 3, y: 6, z: 9, country: 'US', label: 'third', lat:40.00, lon:-75.5} - , {id: 3, x: 4, y: 8, z: 12, country: 'UK', label: 'fourth', lat:57.27, lon:-6.20} - , {id: 4, x: 5, y: 10, z: 15, country: 'UK', label: 'fifth', lat:51.58, lon:0} - , {id: 5, x: 6, y: 12, z: 18, country: 'DE', label: 'sixth', lat:51.04, lon:7.9} - ] - }; - var backend = new recline.Backend.Memory(); - backend.addDataset(inData); - var dataset = new recline.Model.Dataset({id: datasetId}, backend); + var dataset = Fixture.getDataset(); dataset.queryState.addFacet('country'); return dataset; } diff --git a/css/graph.css b/css/graph.css index 88acf5f8..413ac14e 100644 --- a/css/graph.css +++ b/css/graph.css @@ -13,6 +13,11 @@ line-height: 13px; } +.recline-graph .graph .alert { + width: 450px; + margin: auto; +} + /********************************************************** * Editor *********************************************************/ diff --git a/css/grid.css b/css/grid.css index aeb9984e..88f0b134 100644 --- a/css/grid.css +++ b/css/grid.css @@ -210,10 +210,6 @@ div.data-table-cell-content-numeric > a.data-table-cell-edit { * Transform Dialog *********************************************************/ -#expression-preview-tabs .ui-tabs-nav li a { - padding: 0.15em 1em; -} - textarea.expression-preview-code { font-family: monospace; height: 5em; diff --git a/css/images/header-screen.png b/css/images/header-screen.png new file mode 100644 index 00000000..0aeb8ca1 Binary files /dev/null and b/css/images/header-screen.png differ diff --git a/css/images/icons/white.png b/css/images/icons/white.png new file mode 100644 index 00000000..76806990 Binary files /dev/null and b/css/images/icons/white.png differ diff --git a/css/images/zigzags.png b/css/images/zigzags.png new file mode 100644 index 00000000..42e40ccb Binary files /dev/null and b/css/images/zigzags.png differ diff --git a/css/map.css b/css/map.css index 829d0c82..f1f2da29 100644 --- a/css/map.css +++ b/css/map.css @@ -18,6 +18,11 @@ } .recline-map .editor select { - width: 100%; + width: 100%; } +.recline-map .editor .editor-options { + margin-top: 10px; + border-top: 1px solid gray; + padding: 5px 0; +} diff --git a/css/site.css b/css/site.css new file mode 100644 index 00000000..bbdd302d --- /dev/null +++ b/css/site.css @@ -0,0 +1,270 @@ +/* +Theme Name: Recline +Description: Layout and styling for reclinejs.com +Author: Sam Smith +Author URI: http://www.mintcanary.com/ +*/ + +/* -------------------------------------------------- + Table of Contents +----------------------------------------------------- +:: General Styles +:: Layout +:: +:: +:: +*/ + +/* --------------------------------------------------- + General Styles +--------------------------------------------------- */ + +@import url(http://fonts.googleapis.com/css?family=Open+Sans:400,400italic,700); + +h1, h2, h3, h4, h5, h6 { + font-family:'Open Sans', Helvetica, Arial, sans-serif; +} + +a { + color: #c7231d; +} +a:hover { + color: #bc130e; +} +a.dotted { + border-bottom-width: 1px; + border-bottom-style: dotted; + border-bottom-color: #333; + color:#333; +} +a.dotted:hover { + text-decoration:none; +} + +.btn-info { + background: #545454; /* Old browsers */ + background: -moz-linear-gradient(top, #545454 0%, #454545 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#545454), color-stop(100%,#454545)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #545454 0%,#454545 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #545454 0%,#454545 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #545454 0%,#454545 100%); /* IE10+ */ + background: linear-gradient(top, #545454 0%,#454545 100%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#545454', endColorstr='#454545',GradientType=0 ); /* IE6-9 */ + border-color: #454545 #454545 #454545; + filter: progid:dximagetransform.microsoft.gradient(enabled=false); + border: 1px solid #454545; + border-bottom-color: #454545; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + color:#FFF; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); +} +.btn-info:hover, +.btn-info:active, +.btn-info.active, +.btn-info.disabled, +.btn-info[disabled] { + background-color: #454545; + background-image:none; + color:#FFF; +} +.btn-info:active, +.btn-info.active { + background-color: #454545 \9; + background-image:none; + color:#FFF; +} + +.btn-large { + padding: 9px 14px; + -webkit-border-radius: 25px; + -moz-border-radius: 25px; + border-radius: 25px; + margin-right:10px; +} + +.btn-primary { + background: #c7231d; /* Old browsers */ + background: -moz-linear-gradient(top, #c7231d 0%, #bc130e 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#c7231d), color-stop(100%,#bc130e)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #c7231d 0%,#bc130e 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #c7231d 0%,#bc130e 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #c7231d 0%,#bc130e 100%); /* IE10+ */ + background: linear-gradient(top, #c7231d 0%,#bc130e 100%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#c7231d', endColorstr='#bc130e',GradientType=0 ); /* IE6-9 */ + border-color: #0055cc #0055cc #003580; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + filter: progid:dximagetransform.microsoft.gradient(enabled=false); +} +.btn-primary:hover, +.btn-primary:active, +.btn-primary.active, +.btn-primary.disabled, +.btn-primary[disabled] { + background-color: #bc130e; + background-image:none; +} +.btn-primary:active, +.btn-primary.active { + background-color: #bc130e \9; +} + +a.btn-large.showtitle[title] { + position:relative; + margin-bottom:26px; + min-width:117px; +} +a.btn-large.showtitle[title]:after { + content: attr(title); + position:absolute; + bottom:-26px; + left:0px; + font-size:12px; + height:26px; + line-height:26px; + min-width:145px; + text-align:center; +} + +.btn-large .icon-large { + height:25px; + width:25px; + margin-top:-2px; + margin-left:-25px; + margin-right:7px; +} +.btn-large .icon-white.icon-large { + background-image: url(images/icons/white.png); + background-repeat: no-repeat; +} +.btn-large .icon-white.icon-arrow-down.icon-large { + background-position: 3px 1px; +} +.btn-large .icon-white.icon-graph.icon-large { + background-position: -50px 4px; +} + +/* --------------------------------------------------- + Layout +--------------------------------------------------- */ + +.navbar .brand { + font-family:'Open Sans', Helvetica, Arial, sans-serif; + font-style:italic; + font-size:18px; + font-weight:400; + letter-spacing:-1px; + line-height:40px; +} + +.navbar .nav > li > a { + padding: 15px 10px; +} + +.navbar-inner { + height:50px; + background: #303030; /* Old browsers */ + background: -moz-linear-gradient(top, #303030 0%, #2d2d2d 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#303030), color-stop(100%,#2d2d2d)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #303030 0%,#2d2d2d 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #303030 0%,#2d2d2d 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #303030 0%,#2d2d2d 100%); /* IE10+ */ + background: linear-gradient(top, #303030 0%,#2d2d2d 100%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#303030', endColorstr='#2d2d2d',GradientType=0 ); /* IE6-9 */ + -webkit-box-shadow:none; + -moz-box-shadow: none; + box-shadow: none; +} + +section { + padding-top:20px; +} + +.page-header { + margin-top:50px; + background: #2d2d2d; /* Old browsers */ + background: -moz-linear-gradient(top, #2d2d2d 0%, #040404 100%); /* FF3.6+ */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#2d2d2d), color-stop(100%,#040404)); /* Chrome,Safari4+ */ + background: -webkit-linear-gradient(top, #2d2d2d 0%,#040404 100%); /* Chrome10+,Safari5.1+ */ + background: -o-linear-gradient(top, #2d2d2d 0%,#040404 100%); /* Opera 11.10+ */ + background: -ms-linear-gradient(top, #2d2d2d 0%,#040404 100%); /* IE10+ */ + background: linear-gradient(top, #2d2d2d 0%,#040404 100%); /* W3C */ + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#2d2d2d', endColorstr='#040404',GradientType=0 ); /* IE6-9 */ + color:#FFF; + padding:0px; + margin-bottom:0px; + border:none; + font-family:'Open Sans', Helvetica, Arial, sans-serif; +} +.page-header a { + color:#FFF; +} +.page-header a.dotted { + border-color:#FFF; +} +.page-header .container { + background-image: url(images/header-screen.png); + background-repeat: no-repeat; + background-position: -3px 0px; +} +.page-header .inner { + padding:0px 0px 30px 40px; + font-size:16px; +} +.page-header .inner ol { + list-style-type:upper-latin; + font-size:20px; + font-style:italic; +} +.page-header .inner .header-button { + display:inline-block; +} + +.page-header:after { + margin-top:-14px; +} + +section.grey { + background-color:#f5f5f5; +} + +section:after { + content: " "; + height:14px; + display:block; + background-image: url(images/zigzags.png); + background-repeat: repeat-x; + background-position: center 1px; +} + +section.grey:after { + background-position: center -50px; +} + +.footer { + background-color:#040404; + color:#CCC; +} +.footer:before { + content: " "; + height:14px; + display:block; + background-image: url(images/zigzags.png); + background-repeat: repeat-x; + background-position: center -100px; + margin-top:-34px; +} +.footer:after { + display:none; +} +.footer .row { + margin-top:15px; + margin-bottom:15px; +} +.footer a { + color:#CCC; +} +.footer a.btn { + color:#333333; +} diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 00000000..a1f8bfae Binary files /dev/null and b/favicon.ico differ diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 00000000..423f84ed Binary files /dev/null and b/images/logo.png differ diff --git a/images/screenshot-1.jpg b/images/screenshot-1.jpg new file mode 100644 index 00000000..eddf1cea Binary files /dev/null and b/images/screenshot-1.jpg differ diff --git a/index.html b/index.html index db4c4b93..c9bc3261 100644 --- a/index.html +++ b/index.html @@ -10,325 +10,356 @@ - - - - -
-
- + +
+
+
+
+

Recline is Two Things

+
+
+
+
+
    +
  • A Data Explorer combining a data grid, Google Refine-style data + transforms and visualizations all in lightweight javascript and html.
  • +
  • A simple but powerful library of extensible of data components - data + grid, graphing, and data connectors - which you can selectively use and build + on.
  • +
+
+
+

The Explorer can be used standalone (just download and use) or can be + embedded into your own site. Recline builds on the powerful but lightweight + Backbone framework making it extremely easy to extend and adapt. The library's + modular design mean means you only have to take what you need.

+
+
+
+
+

Main Features

+
    +
  • View and edit your data in a clean grid / table interface
  • +
  • Bulk update/clean your data using an easy scripting UI
  • +
  • Easily extensible with new Backends so you can connect to your + database or storage layer
  • +
  • Visualize data
  • +
  • Open-source, pure javascript and designed for integration -- so it is + easy to embed in other sites and applications
  • +
  • Built on the simple but powerful Backbone giving a + clean and robust design which is easy to extend
  • +
  • Properly designed model with clean separation of data and presentation
  • +
  • Componentized design means you use only what you need
  • +
+
+
+ Recline Data Explorer Screenshot +
+
+
+
-

Recline is two things:

+
+
+
+
+

Data Explorer Documentation

+
+
+
+
+

Usage instructions are built into the Data Explorer + itself so no specific additional documentation is provided on usage.

+

To embed the data explorer in another site you can use a simple iframe in + your web page:

+
+
+ +
+
+
+
+

Alternatively, you can initialize the explorer yourself from javascript. To + see how to do this just take at look at the Explorer's initialization + javascript in: app.js.

+
+
+
+
+

Library Documentation

+ +

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

Concepts and Structure

+
+
+
+
+ +

Recline has a simple structure layered on top of the basic Model/View + distinction inherent in Backbone.

+ +

Models

+

There are two main model objects:

+
    +
  • Dataset: represents the dataset. + Holds dataset info and a pointer to list of data items (Documents in our + terminology) which it can load from the relevant Backend.
  • +
  • Document: an individual data item + (e.g. a row from a relational database or a spreadsheet, a document from from + a document DB like CouchDB or MongoDB).
  • +
+ +

Additional, related models:

+
    +
  • Field: a field/column on a + dataset.
  • +
  • Query: an object to encapsulate a + query to the backend (useful both for creating queries and for storing and + manipulating query state - e.g. from a query editor).
  • +
  • Facet: Object to store Facet + information, that is summary information (e.g. values and counts) about a + field obtained by some faceting method on the backend.
  • +
+ +

More detail of how these work can be found in the Model source docs.

+ + +
-
    -
  • A Data Explorer combining a data grid, Google Refine-style data - transforms and visualizations all in lightweight javascript and html.
  • -
  • A simple but powerful library of extensible of data components - data - grid, graphing, and data connectors - which you can selectively use and build - on.
  • -
+
+

Backends

+

Backends connect Dataset and Documents to data from a + specific 'Backend' data source. They provide methods for loading and saving + Datasets and individuals Documents as well as for bulk loading via a query API + and doing bulk transforms on the backend.

+ +

A template Base class can be found in the + Backend base module of the source docs. It documents both the relevant + methods a Backend must have and (optionally) provides a base 'class' for + inheritance. You can also find detailed examples of backend implementations in + the source documentation below.

+ +

Views

+

Complementing the model are various Views (you can + also easily write your own). Each view holds a pointer to a Dataset:

+
    +
  • DataExplorer: the parent view which manages the overall app and sets up + sub views.
  • +
  • Grid: the data grid view.
  • +
  • Graph: a simple graphing view using Flot.
  • +
  • Map: a map view using Leaflet.
  • +
+ +

There are additional views which do not display a whole dataset but which + are useful:

+
    +
  • QueryEditor: a query editor view
  • +
  • FacetViewer: display facets
  • +
+
+
+
+
+

Source Docs (via Docco)

+
+
+ +
+
+

Tests

+

Run the tests online.

+
+
+
+
+

History

+
+
+
+
+

Max Ogden was developing Recline as the frontend data browser and editor for + his http://datacouch.com/ project. + Meanwhile, Rufus Pollock and the CKAN team at + the Open Knowledge Foundation had been working + on a Data Explorer for use in + the DataHub and CKAN software.

+ +

When they met up, they realized that they were pretty much working on the + same thing and so decided to join forces to produce the new Recline Data + Explorer.

+
+
+

The new project forked off Max's original recline + codebase combining some portions of the original Data Explorer. + However, it has been rewritten from the ground up using Backbone.

+
+
+
+
-

The Explorer can be used standalone (just download and use) or can be -embedded into your own site. Recline builds on the powerful but lightweight -Backbone framework making it extremely easy to extend and adapt. The library's -modular design mean means you only have to take what you need.

+ -

Main Features

-
    -
  • View and edit your data in a clean grid / table interface
  • -
  • Bulk update/clean your data using an easy scripting UI
  • -
  • Easily extensible with new Backends so you can connect to your - database or storage layer
  • -
  • Visualize data
  • -
  • Open-source, pure javascript and designed for integration -- so it is - easy to embed in other sites and applications
  • -
  • Built on the simple but powerful Backbone giving a - clean and robust design which is easy to extend
  • -
  • Properly designed model with clean separation of data and presentation
  • -
  • Componentized design means you use only what you need
  • -
- -

Screenshots

-

Recline Data Explorer Screenshot

- -

Demo

-

For demo see the Data Explorer »

- -

Data Explorer Documentation

- -

Usage instructions are built into the Data Explorer -itself so no specific additional documentation is provided on usage.

- -

To embed the data explorer in another site you can use a simple iframe in -your web page:

- - - -

Alternatively, you can initialize the explorer yourself from javascript. To -see how to do this just take at look at the Explorer's initialization -javascript in: app.js.

- - -

Library Documentation

- -

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

Concepts and Structure

- -

Recline has a simple structure layered on top of the basic Model/View -distinction inherent in Backbone.

- -

Models

-

There are two main model objects:

-
    -
  • Dataset: represents the dataset. - Holds dataset info and a pointer to list of data items (Documents in our - terminology) which it can load from the relevant Backend.
  • -
  • Document: an individual data item - (e.g. a row from a relational database or a spreadsheet, a document from from - a document DB like CouchDB or MongoDB).
  • -
- -

Additional, related models:

-
    -
  • Field: a field/column on a - dataset.
  • -
  • Query: an object to encapsulate a - query to the backend (useful both for creating queries and for storing and - manipulating query state - e.g. from a query editor).
  • -
  • Facet: Object to store Facet - information, that is summary information (e.g. values and counts) about a - field obtained by some faceting method on the backend.
  • -
- -

More detail of how these work can be found in the Model source docs.

- -

Backends

-

Backends connect Dataset and Documents to data from a -specific 'Backend' data source. They provide methods for loading and saving -Datasets and individuals Documents as well as for bulk loading via a query API -and doing bulk transforms on the backend.

- -

A template Base class can be found in the - Backend base module of the source docs. It documents both the relevant -methods a Backend must have and (optionally) provides a base 'class' for -inheritance. You can also find detailed examples of backend implementations in -the source documentation below.

- -

Views

-

Complementing the model are various Views (you can -also easily write your own). Each view holds a pointer to a Dataset:

-
    -
  • DataExplorer: the parent view which manages the overall app and sets up - sub views.
  • -
  • Grid: the data grid view.
  • -
  • Graph: a simple graphing view using Flot.
  • -
  • Map: a map view using Leaflet.
  • -
- -

There are additional views which do not display a whole dataset but which -are useful:

-
    -
  • QueryEditor: a query editor view
  • -
  • FacetViewer: display facets
  • -
- - -

Source Docs (via Docco)

-

Models and Views (Widgets)

- - -

Backends

- - -

Tests

-

Run the tests online.

- -

History

-

Max Ogden was developing Recline as the frontend data browser and editor for -his http://datacouch.com/ project. -Meanwhile, Rufus Pollock and the CKAN team at -the Open Knowledge Foundation had been working -on a Data Explorer for use in -the DataHub and CKAN software.

- -

When they met up, they realized that they were pretty much working on the -same thing and so decided to join forces to produce the new Recline Data -Explorer.

- -

The new project forked off Max's original recline - codebase combining some portions of the original Data Explorer. -However, it has been rewritten from the ground up using Backbone.

- -
- -
diff --git a/recline.js b/recline.js index 391296aa..271e9c54 100644 --- a/recline.js +++ b/recline.js @@ -757,22 +757,13 @@ my.Graph = Backbone.View.extend({ \
\ \
\
\ -
\ - \ -
\ - \ -
\ -
\
\ \
\ @@ -784,13 +775,34 @@ my.Graph = Backbone.View.extend({
\ \ \ -
\ +
\ +
\ +

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.

\ +
\ +
\ \ ', + templateSeriesEditor: ' \ +
\ + \ +
\ + \ +
\ +
\ + ', events: { 'change form select': 'onEditorSubmit', - 'click .editor-add': 'addSeries', + 'click .editor-add': '_onAddSeries', 'click .action-remove-series': 'removeSeries', 'click .action-toggle-help': 'toggleHelp' }, @@ -807,7 +819,8 @@ my.Graph = Backbone.View.extend({ this.model.currentDocuments.bind('reset', this.redraw); var stateData = _.extend({ group: null, - series: [], + // so that at least one series chooser box shows up + series: [""], graphType: 'lines-and-points' }, options.state @@ -817,21 +830,45 @@ my.Graph = Backbone.View.extend({ }, render: function() { - htmls = $.mustache(this.template, this.model.toTemplateJSON()); + var self = this; + var tmplData = this.model.toTemplateJSON(); + var htmls = $.mustache(this.template, tmplData); $(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(); + + // 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')); + } + _.each(this.state.get('series'), 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 = { @@ -870,10 +907,20 @@ my.Graph = Backbone.View.extend({ // } }, + // ### getGraphOptions + // + // Get options for Flot Graph + // // needs to be function as can depend on state + // + // @param typeId graphType id (lines, lines-and-points etc) getGraphOptions: function(typeId) { var self = this; // 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); @@ -886,20 +933,25 @@ my.Graph = Backbone.View.extend({ } 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': { @@ -907,6 +959,7 @@ my.Graph = Backbone.View.extend({ points: { show: true }, lines: { show: true } }, + xaxis: xaxis, grid: { hoverable: true, clickable: true } }, bars: { @@ -930,7 +983,7 @@ my.Graph = Backbone.View.extend({ } } }; - return options[typeId]; + return optionsPerGraphType[typeId]; }, setupTooltips: function() { @@ -987,8 +1040,15 @@ my.Graph = Backbone.View.extend({ _.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; } @@ -1006,23 +1066,25 @@ my.Graph = Backbone.View.extend({ // 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; + 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(' [Remove]'); - 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; }, + _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. @@ -1030,26 +1092,12 @@ my.Graph = Backbone.View.extend({ 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); @@ -1199,6 +1247,8 @@ my.Grid = Backbone.View.extend({ var hiddenFields = this.state.get('hiddenFields'); hiddenFields.push(this.tempState.currentColumn); this.state.set({hiddenFields: hiddenFields}); + // change event not being triggered (because it is an array?) so trigger manually + this.state.trigger('change'); this.render(); }, @@ -1404,7 +1454,7 @@ this.recline.View = this.recline.View || {}; // //
 //   {
-//     // 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}
@@ -1462,6 +1512,11 @@ my.Map = Backbone.View.extend({
       
\ \
\ +
\ + \ +
\ \ \ \ @@ -1479,7 +1534,8 @@ my.Map = Backbone.View.extend({ // 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) { @@ -1498,15 +1554,27 @@ my.Map = Backbone.View.extend({ // 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(); + // 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({ @@ -1518,6 +1586,7 @@ my.Map = Backbone.View.extend({ ); this.state = new recline.Model.ObjectState(stateData); + this.autoZoom = true; this.mapReady = false; this.render(); }, @@ -1583,6 +1652,13 @@ my.Map = Backbone.View.extend({ this.features.clearLayers(); this._add(this.model.currentDocuments.models); } + if (action != 'reset' && this.autoZoom){ + if (this.visible){ + this._zoomToFeatures(); + } else { + this._zoomPending = true; + } + } } }, @@ -1629,6 +1705,10 @@ my.Map = Backbone.View.extend({ } }, + onAutoZoomChange: function(e){ + this.autoZoom = !this.autoZoom; + }, + // Private: Add one or n features to the map // // For each document passed, a GeoJSON geometry will be extracted and added @@ -1656,7 +1736,9 @@ my.Map = Backbone.View.extend({ // TODO: mustache? html = '' for (key in doc.attributes){ - html += '
' + key + ': '+ doc.attributes[key] + '
' + if (!(self.state.get('geomField') && key == self.state.get('geomField'))){ + html += '
' + key + ': '+ doc.attributes[key] + '
'; + } } feature.properties = {popupContent: html}; @@ -1707,19 +1789,22 @@ my.Map = Backbone.View.extend({ _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')]; + 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] }; } } @@ -1761,6 +1846,18 @@ my.Map = Backbone.View.extend({ return null; }, + // Private: Zoom to map to current features extent if any, or to the full + // extent if none. + // + _zoomToFeatures: function(){ + var bounds = this.features.getBounds(); + if (bounds){ + 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](http://www.mapquest.com) based @@ -1785,6 +1882,24 @@ my.Map = Backbone.View.extend({ } }); + + // 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); @@ -2411,7 +2526,9 @@ my.DataExplorer = Backbone.View.extend({ pageView.view.state.bind('change', function() { var update = {}; update['view-' + pageView.id] = pageView.view.state.toJSON(); - self.state.set(update); + // 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'); }); } }); @@ -3404,7 +3521,9 @@ this.recline.Backend = this.recline.Backend || {}; var options = options || {}; var trm = options.trim; var separator = options.separator || ','; - + var delimiter = options.delimiter || '"'; + + var cur = '', // The character we are currently processing. inQuote = false, fieldQuoted = false, @@ -3451,8 +3570,8 @@ this.recline.Backend = this.recline.Backend || {}; field = ''; fieldQuoted = false; } else { - // If it's not a ", add it to the field buffer - if (cur !== '"') { + // If it's not a delimiter, add it to the field buffer + if (cur !== delimiter) { field += cur; } else { if (!inQuote) { @@ -3460,9 +3579,9 @@ this.recline.Backend = this.recline.Backend || {}; inQuote = true; fieldQuoted = true; } else { - // Next char is ", this is an escaped " - if (s.charAt(i + 1) === '"') { - field += '"'; + // Next char is delimiter, this is an escaped delimiter + if (s.charAt(i + 1) === delimiter) { + field += delimiter; // Skip the next char i += 1; } else { diff --git a/src/backend/localcsv.js b/src/backend/localcsv.js index e5a6bdec..d1969fa2 100644 --- a/src/backend/localcsv.js +++ b/src/backend/localcsv.js @@ -58,7 +58,9 @@ this.recline.Backend = this.recline.Backend || {}; var options = options || {}; var trm = options.trim; var separator = options.separator || ','; - + var delimiter = options.delimiter || '"'; + + var cur = '', // The character we are currently processing. inQuote = false, fieldQuoted = false, @@ -105,8 +107,8 @@ this.recline.Backend = this.recline.Backend || {}; field = ''; fieldQuoted = false; } else { - // If it's not a ", add it to the field buffer - if (cur !== '"') { + // If it's not a delimiter, add it to the field buffer + if (cur !== delimiter) { field += cur; } else { if (!inQuote) { @@ -114,9 +116,9 @@ this.recline.Backend = this.recline.Backend || {}; inQuote = true; fieldQuoted = true; } else { - // Next char is ", this is an escaped " - if (s.charAt(i + 1) === '"') { - field += '"'; + // Next char is delimiter, this is an escaped delimiter + if (s.charAt(i + 1) === delimiter) { + field += delimiter; // Skip the next char i += 1; } else { diff --git a/src/util.js b/src/util.js index cd0086be..55e9390a 100644 --- a/src/util.js +++ b/src/util.js @@ -1,153 +1,79 @@ /*jshint multistr:true */ -var util = function() { - var templates = { - transformActions: '
  • Global transform...
  • ', - cellEditor: ' \ - \ - ', - editPreview: ' \ -
    \ - \ - \ - \ - \ - \ - \ - \ - \ - {{#rows}} \ - \ - \ - \ - \ - {{/rows}} \ - \ -
    \ - before \ - \ - after \ -
    \ - {{before}} \ - \ - {{after}} \ -
    \ -
    \ - ' - }; +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 - }; -}(); diff --git a/src/view-graph.js b/src/view-graph.js index 08695a92..d9fe7873 100644 --- a/src/view-graph.js +++ b/src/view-graph.js @@ -48,22 +48,13 @@ my.Graph = Backbone.View.extend({ \
    \ \
    \
    \ -
    \ - \ -
    \ - \ -
    \ -
    \
    \ \
    \ @@ -75,13 +66,34 @@ my.Graph = Backbone.View.extend({
    \ \ \ -
    \ +
    \ +
    \ +

    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.

    \ +
    \ +
    \ \ ', + templateSeriesEditor: ' \ +
    \ + \ +
    \ + \ +
    \ +
    \ + ', events: { 'change form select': 'onEditorSubmit', - 'click .editor-add': 'addSeries', + 'click .editor-add': '_onAddSeries', 'click .action-remove-series': 'removeSeries', 'click .action-toggle-help': 'toggleHelp' }, @@ -98,7 +110,8 @@ my.Graph = Backbone.View.extend({ this.model.currentDocuments.bind('reset', this.redraw); var stateData = _.extend({ group: null, - series: [], + // so that at least one series chooser box shows up + series: [""], graphType: 'lines-and-points' }, options.state @@ -108,21 +121,45 @@ my.Graph = Backbone.View.extend({ }, render: function() { - htmls = $.mustache(this.template, this.model.toTemplateJSON()); + var self = this; + var tmplData = this.model.toTemplateJSON(); + var htmls = $.mustache(this.template, tmplData); $(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(); + + // 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')); + } + _.each(this.state.get('series'), 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 = { @@ -161,10 +198,20 @@ my.Graph = Backbone.View.extend({ // } }, + // ### getGraphOptions + // + // Get options for Flot Graph + // // needs to be function as can depend on state + // + // @param typeId graphType id (lines, lines-and-points etc) getGraphOptions: function(typeId) { var self = this; // 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); @@ -177,20 +224,25 @@ my.Graph = Backbone.View.extend({ } 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': { @@ -198,6 +250,7 @@ my.Graph = Backbone.View.extend({ points: { show: true }, lines: { show: true } }, + xaxis: xaxis, grid: { hoverable: true, clickable: true } }, bars: { @@ -221,7 +274,7 @@ my.Graph = Backbone.View.extend({ } } }; - return options[typeId]; + return optionsPerGraphType[typeId]; }, setupTooltips: function() { @@ -278,8 +331,15 @@ my.Graph = Backbone.View.extend({ _.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; } @@ -297,23 +357,25 @@ my.Graph = Backbone.View.extend({ // 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; + 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(' [Remove]'); - 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; }, + _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. @@ -321,26 +383,12 @@ my.Graph = Backbone.View.extend({ 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/src/view-grid.js b/src/view-grid.js index cb959f18..30e81a33 100644 --- a/src/view-grid.js +++ b/src/view-grid.js @@ -35,18 +35,6 @@ my.Grid = Backbone.View.extend({ '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 @@ -81,12 +69,12 @@ my.Grid = Backbone.View.extend({ filter: function() { self.model.queryState.addTermFilter(self.tempState.currentColumn, ''); }, - transform: function() { self.showTransformDialog('transform'); }, sortAsc: function() { self.setColumnSort('asc'); }, sortDesc: function() { self.setColumnSort('desc'); }, hideColumn: function() { self.hideColumn(); }, showColumn: function() { self.showColumn(e); }, deleteRow: function() { + 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 @@ -94,9 +82,9 @@ my.Grid = Backbone.View.extend({ }); 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}); }); } }; @@ -104,33 +92,18 @@ my.Grid = Backbone.View.extend({ }, showTransformColumnDialog: function() { - var $el = $('.dialog-content'); - util.show('dialog'); + 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(); - $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) { @@ -143,6 +116,8 @@ my.Grid = Backbone.View.extend({ var hiddenFields = this.state.get('hiddenFields'); hiddenFields.push(this.tempState.currentColumn); this.state.set({hiddenFields: hiddenFields}); + // change event not being triggered (because it is an array?) so trigger manually + this.state.trigger('change'); this.render(); }, @@ -291,6 +266,19 @@ my.GridRow = Backbone.View.extend({ // =================== // Cell Editor methods + + cellEditorTemplate: ' \ + \ + ', + onEditClick: function(e) { var editing = this.el.find('.data-table-cell-editor-editor'); if (editing.length > 0) { @@ -299,10 +287,12 @@ my.GridRow = Backbone.View.extend({ $(e.target).addClass("hidden"); var cell = $(e.target).siblings('.data-table-cell-value'); cell.data("previousContents", cell.text()); - util.render('cellEditor', cell, {value: cell.text()}); + var templated = $.mustache(this.cellEditorTemplate, {value: cell.text()}); + cell.html(templated); }, onEditorOK: function(e) { + var self = this; var cell = $(e.target); var rowId = cell.parents('tr').attr('data-id'); var field = cell.parents('td').attr('data-field'); @@ -310,12 +300,13 @@ my.GridRow = Backbone.View.extend({ var newData = {}; newData[field] = newValue; this.model.set(newData); - my.notify("Updating row...", {loader: true}); + this.trigger('recline:flash', {message: "Updating row...", loader: true}); this.model.save().then(function(response) { - my.notify("Row updated successfully", {category: 'success'}); + this.trigger('recline:flash', {message: "Row updated successfully", category: 'success'}); }) .fail(function() { - my.notify('Error saving row', { + this.trigger('recline:flash', { + message: 'Error saving row', category: 'error', persist: true }); diff --git a/src/view-map.js b/src/view-map.js index 9553b9e3..5b2f3433 100644 --- a/src/view-map.js +++ b/src/view-map.js @@ -17,7 +17,7 @@ this.recline.View = this.recline.View || {}; // //
     //   {
    -//     // 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}
    @@ -75,6 +75,11 @@ my.Map = Backbone.View.extend({
           
    \ \
    \ +
    \ + \ +
    \ \ \ \ @@ -92,7 +97,8 @@ my.Map = Backbone.View.extend({ // 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) { @@ -111,15 +117,27 @@ my.Map = Backbone.View.extend({ // 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(); + // 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({ @@ -131,6 +149,7 @@ my.Map = Backbone.View.extend({ ); this.state = new recline.Model.ObjectState(stateData); + this.autoZoom = true; this.mapReady = false; this.render(); }, @@ -196,6 +215,13 @@ my.Map = Backbone.View.extend({ this.features.clearLayers(); this._add(this.model.currentDocuments.models); } + if (action != 'reset' && this.autoZoom){ + if (this.visible){ + this._zoomToFeatures(); + } else { + this._zoomPending = true; + } + } } }, @@ -242,6 +268,10 @@ my.Map = Backbone.View.extend({ } }, + onAutoZoomChange: function(e){ + this.autoZoom = !this.autoZoom; + }, + // Private: Add one or n features to the map // // For each document passed, a GeoJSON geometry will be extracted and added @@ -251,7 +281,6 @@ my.Map = Backbone.View.extend({ // Each feature will have a popup associated with all the document fields. // _add: function(docs){ - var self = this; if (!(docs instanceof Array)) docs = [docs]; @@ -269,7 +298,9 @@ my.Map = Backbone.View.extend({ // TODO: mustache? html = '' for (key in doc.attributes){ - html += '
    ' + key + ': '+ doc.attributes[key] + '
    ' + if (!(self.state.get('geomField') && key == self.state.get('geomField'))){ + html += '
    ' + key + ': '+ doc.attributes[key] + '
    '; + } } feature.properties = {popupContent: html}; @@ -284,13 +315,13 @@ my.Map = Backbone.View.extend({ var msg = 'Wrong geometry value'; if (except.message) msg += ' (' + except.message + ')'; if (wrongSoFar <= 10) { - my.notify(msg,{category:'error'}); + self.trigger('recline:flash', {message: msg, category:'error'}); } } } else { wrongSoFar += 1 if (wrongSoFar <= 10) { - my.notify('Wrong geometry value',{category:'error'}); + self.trigger('recline:flash', {message: 'Wrong geometry value', category:'error'}); } } return true; @@ -320,19 +351,22 @@ my.Map = Backbone.View.extend({ _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')]; + 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] }; } } @@ -374,6 +408,18 @@ my.Map = Backbone.View.extend({ return null; }, + // Private: Zoom to map to current features extent if any, or to the full + // extent if none. + // + _zoomToFeatures: function(){ + var bounds = this.features.getBounds(); + if (bounds){ + 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](http://www.mapquest.com) based @@ -398,6 +444,24 @@ my.Map = Backbone.View.extend({ } }); + + // 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); diff --git a/src/view-transform-dialog.js b/src/view-transform-dialog.js index 12f83872..6c69766b 100644 --- a/src/view-transform-dialog.js +++ b/src/view-transform-dialog.js @@ -6,82 +6,17 @@ this.recline.View = this.recline.View || {}; // Views module following classic module pattern (function($, my) { -// View (Dialog) for doing data transformations on whole dataset. -my.DataTransform = Backbone.View.extend({ - className: 'transform-view', - template: ' \ -
    \ - Recursive transform on all rows \ -
    \ -
    \ -
    \ -

    Traverse and transform objects by visiting every node on a recursive walk using js-traverse.

    \ - \ - \ - \ - \ - \ - \ -
    \ -
    \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ - \ -
    \ - Expression \ -
    \ -
    \ - \ -
    \ -
    \ - No syntax error. \ -
    \ -
    \ - Preview \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ -
    \ - \ - ', - - initialize: function() { - this.el = $(this.el); - }, - - render: function() { - this.el.html(this.template); - } -}); - - +// ## ColumnTransform +// // View (Dialog) for doing data transformations (on columns of data). my.ColumnTransform = Backbone.View.extend({ - className: 'transform-column-view', + className: 'transform-column-view modal fade in', template: ' \ -
    \ - Functional transform on column {{name}} \ + \ -
    \ + \ - \
    \ - \ - \
    \ ', events: { @@ -215,6 +209,7 @@ my.DataExplorer = Backbone.View.extend({ // these must be called after pageViews are created this.render(); this._bindStateChanges(); + this._bindFlashNotifications(); // now do updates based on state (need to come after render) if (this.state.get('readOnly')) { this.setReadOnly(); @@ -225,24 +220,16 @@ my.DataExplorer = Backbone.View.extend({ 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; @@ -256,7 +243,7 @@ my.DataExplorer = Backbone.View.extend({ } else { msg = 'There was an error querying the backend'; } - my.notify(msg, {category: 'error', persist: true}); + self.notify({message: msg, category: 'error', persist: true}); }); // retrieve basic data like fields etc @@ -266,7 +253,7 @@ my.DataExplorer = Backbone.View.extend({ 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}); }); }, @@ -299,21 +286,6 @@ my.DataExplorer = Backbone.View.extend({ 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'); @@ -357,7 +329,7 @@ my.DataExplorer = Backbone.View.extend({ _setupState: function(initialState) { var self = this; // get data from the query string / hash url plus some defaults - var qs = my.parseHashQueryString(); + 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) @@ -391,10 +363,63 @@ my.DataExplorer = Backbone.View.extend({ pageView.view.state.bind('change', function() { var update = {}; update['view-' + pageView.id] = pageView.view.state.toJSON(); - self.state.set(update); + // 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 = ' \ +
    × \ + {{message}} \ + {{#loader}} \ +   \ + {{/loader}} \ +
    '; + 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(); } }); @@ -635,118 +660,6 @@ my.FacetViewer = Backbone.View.extend({ } }); -/* ========================================================== */ -// ## 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 = ' \ -
    × \ - {{msg}} \ - {{#loader}} \ -   \ - {{/loader}} \ -
    '; - 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/test/backend.localcsv.test.js b/test/backend.localcsv.test.js index bc2f2738..8325e549 100644 --- a/test/backend.localcsv.test.js +++ b/test/backend.localcsv.test.js @@ -1,7 +1,7 @@ (function ($) { module("Backend Local CSV"); -test("parseCSV", function() { +test("parseCSV", function() { var csv = '"Jones, Jay",10\n' + '"Xyz ""ABC"" O\'Brien",11:35\n' + '"Other, AN",12:35\n'; @@ -29,7 +29,7 @@ test("parseCSV", function() { equal(dataset.currentDocuments.length, 3); }); -test("parseCSVsemicolon", function() { +test("parseCSVsemicolon", function() { var csv = '"Jones; Jay";10\n' + '"Xyz ""ABC"" O\'Brien";11:35\n' + '"Other; AN";12:35\n'; @@ -44,4 +44,20 @@ test("parseCSVsemicolon", function() { }); +test("parseCSVdelimiter", function() { + var csv = "'Jones, Jay',10\n" + + "'Xyz \"ABC\" O''Brien',11:35\n" + + "'Other; AN',12:35\n"; + + var array = recline.Backend.parseCSV(csv, {delimiter:"'"}); + var exp = [ + ["Jones, Jay", 10], + ["Xyz \"ABC\" O'Brien", "11:35" ], + ["Other; AN", "12:35" ] + ]; + deepEqual(exp, array); + +}); + + })(this.jQuery); diff --git a/test/base.js b/test/base.js index 729e1178..35985fdd 100644 --- a/test/base.js +++ b/test/base.js @@ -1,6 +1,8 @@ var Fixture = { getDataset: function() { var fields = [ + {id: 'id'}, + {id: 'date', type: 'date'}, {id: 'x'}, {id: 'y'}, {id: 'z'}, @@ -10,12 +12,12 @@ var Fixture = { {id: 'lon'} ]; var documents = [ - {id: 0, x: 1, y: 2, z: 3, country: 'DE', label: 'first', lat:52.56, lon:13.40}, - {id: 1, x: 2, y: 4, z: 6, country: 'UK', label: 'second', lat:54.97, lon:-1.60}, - {id: 2, x: 3, y: 6, z: 9, country: 'US', label: 'third', lat:40.00, lon:-75.5}, - {id: 3, x: 4, y: 8, z: 12, country: 'UK', label: 'fourth', lat:57.27, lon:-6.20}, - {id: 4, x: 5, y: 10, z: 15, country: 'UK', label: 'fifth', lat:51.58, lon:0}, - {id: 5, x: 6, y: 12, z: 18, country: 'DE', label: 'sixth', lat:51.04, lon:7.9} + {id: 0, date: '2011-01-01', x: 1, y: 2, z: 3, country: 'DE', label: 'first', lat:52.56, lon:13.40}, + {id: 1, date: '2011-02-02', x: 2, y: 4, z: 6, country: 'UK', label: 'second', lat:54.97, lon:-1.60}, + {id: 2, date: '2011-03-03', x: 3, y: 6, z: 9, country: 'US', label: 'third', lat:40.00, lon:-75.5}, + {id: 3, date: '2011-04-04', x: 4, y: 8, z: 12, country: 'UK', label: 'fourth', lat:57.27, lon:-6.20}, + {id: 4, date: '2011-05-04', x: 5, y: 10, z: 15, country: 'UK', label: 'fifth', lat:51.58, lon:0}, + {id: 5, date: '2011-06-02', x: 6, y: 12, z: 18, country: 'DE', label: 'sixth', lat:51.04, lon:7.9} ]; var dataset = recline.Backend.createDataset(documents, fields); return dataset; diff --git a/test/built.html b/test/built.html new file mode 100644 index 00000000..8e3ddfb1 --- /dev/null +++ b/test/built.html @@ -0,0 +1,52 @@ + + + + Qunit Tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    Qunit Tests

    +

    +
    +

    +
      +
      + +
      + +
      +
      + + diff --git a/test/index.html b/test/index.html index 8bc78f41..8e25198c 100644 --- a/test/index.html +++ b/test/index.html @@ -10,7 +10,6 @@ - @@ -22,6 +21,7 @@ + @@ -42,6 +42,7 @@ + diff --git a/test/util.test.js b/test/util.test.js index e6711c0d..c4d7f930 100644 --- a/test/util.test.js +++ b/test/util.test.js @@ -2,10 +2,10 @@ module("Util"); test('parseHashUrl', function () { - var out = recline.View.parseHashUrl('graph?x=y'); + var out = recline.Util.parseHashUrl('graph?x=y'); equal(out.path, 'graph'); equal(out.query, '?x=y'); - var out = recline.View.parseHashUrl('graph'); + var out = recline.Util.parseHashUrl('graph'); equal(out.path, 'graph'); equal(out.query, ''); }); @@ -15,7 +15,7 @@ test('composeQueryString', function () { x: 'y', a: 'b' }; - var out = recline.View.composeQueryString(params); + var out = recline.Util.composeQueryString(params); equal(out, '?x=y&a=b'); }); diff --git a/test/view-graph.test.js b/test/view-graph.test.js index f7e6dae5..3b015f12 100644 --- a/test/view-graph.test.js +++ b/test/view-graph.test.js @@ -11,3 +11,43 @@ test('basics', function () { assertPresent('.editor', view.el); view.remove(); }); + +test('initialize', function () { + var dataset = Fixture.getDataset(); + var view = new recline.View.Graph({ + model: dataset, + state: { + 'graphType': 'lines', + 'group': 'x', + 'series': ['y', 'z'] + } + }); + $('.fixtures').append(view.el); + equal(view.state.get('graphType'), 'lines'); + deepEqual(view.state.get('series'), ['y', 'z']); + + // check we have updated editor with state info + equal(view.el.find('.editor-type select').val(), 'lines'); + equal(view.el.find('.editor-group select').val(), 'x'); + var out = _.map(view.el.find('.editor-series select'), function($el) { + return $($el).val(); + }); + deepEqual(out, ['y', 'z']); + + view.remove(); +}); + +test('dates in graph view', function () { + var dataset = Fixture.getDataset(); + var view = new recline.View.Graph({ + model: dataset, + state: { + 'graphType': 'lines', + 'group': 'date', + 'series': ['y', 'z'] + } + }); + $('.fixtures').append(view.el); + + view.remove(); +}); diff --git a/test/view-map.test.js b/test/view-map.test.js new file mode 100644 index 00000000..aa07f9a8 --- /dev/null +++ b/test/view-map.test.js @@ -0,0 +1,132 @@ +(function ($) { + +module("View - Map"); + +var GeoJSONFixture = { + getDataset: function() { + var fields = [ + {id: 'id'}, + {id: 'x'}, + {id: 'y'}, + {id: 'z'}, + {id: 'geom'} + ]; + var documents = [ + {id: 0, x: 1, y: 2, z: 3, geom: '{"type":"Point","coordinates":[13.40,52.35]}'}, + {id: 1, x: 2, y: 4, z: 6, geom: {type:"Point",coordinates:[13.40,52.35]}}, + {id: 2, x: 3, y: 6, z: 9, geom: {type:"LineString",coordinates:[[100.0, 0.0],[101.0, 1.0]]}} + ]; + var dataset = recline.Backend.createDataset(documents, fields); + return dataset; + } +}; + +test('basics', function () { + var dataset = Fixture.getDataset(); + var view = new recline.View.Map({ + model: dataset + }); + $('.fixtures').append(view.el); + + //Fire query, otherwise the map won't be initialized + dataset.query(); + + assertPresent('.editor',view.el); + + // Check that the Leaflet map was set up + assertPresent('.leaflet-container',view.el); + + ok(view.map instanceof L.Map); + ok(view.features instanceof L.GeoJSON); + + view.remove(); +}); + +test('Lat/Lon geom fields', function () { + var dataset = Fixture.getDataset(); + var view = new recline.View.Map({ + model: dataset + }); + $('.fixtures').append(view.el); + + //Fire query, otherwise the map won't be initialized + dataset.query(); + + // Check that all markers were created + equal(_getFeaturesCount(view.features),6); + + // Delete a document + view.model.currentDocuments.remove(view.model.currentDocuments.get('1')); + equal(_getFeaturesCount(view.features),5); + + // Add a new one + view.model.currentDocuments.add({id: 7, x: 7, y: 14, z: 21, country: 'KX', label: 'seventh', lat:13.23, lon:23.56}), + equal(_getFeaturesCount(view.features),6); + + view.remove(); +}); + +test('GeoJSON geom field', function () { + var dataset = GeoJSONFixture.getDataset(); + var view = new recline.View.Map({ + model: dataset + }); + $('.fixtures').append(view.el); + + //Fire query, otherwise the map won't be initialized + dataset.query(); + + // Check that all features were created + equal(_getFeaturesCount(view.features),3); + + // Delete a document + view.model.currentDocuments.remove(view.model.currentDocuments.get('2')); + equal(_getFeaturesCount(view.features),2); + + // Add it back + view.model.currentDocuments.add({id: 2, x: 3, y: 6, z: 9, geom: {type:"LineString",coordinates:[[100.0, 0.0],[101.0, 1.0]]}}), + equal(_getFeaturesCount(view.features),3); + + view.remove(); +}); + +test('Popup', function () { + var dataset = GeoJSONFixture.getDataset(); + var view = new recline.View.Map({ + model: dataset + }); + $('.fixtures').append(view.el); + + //Fire query, otherwise the map won't be initialized + dataset.query(); + + var marker = view.el.find('.leaflet-marker-icon').first(); + + assertPresent(marker); + + _.values(view.features._layers)[0].fire('click'); + + var popup = view.el.find('.leaflet-popup-content'); + + assertPresent(popup); + + var text = popup.text(); + ok((text.indexOf('geom') === -1)) + _.each(view.model.fields.toJSON(),function(field){ + if (field.id != 'geom'){ + ok((text.indexOf(field.id) !== -1)) + } + }); + + view.remove(); +}); + +var _getFeaturesCount = function(features){ + var cnt = 0; + features._iterateLayers(function(layer){ + cnt++; + }); + return cnt; +} + +})(this.jQuery); diff --git a/vendor/jquery-ui-1.8.14.custom.min.js b/vendor/jquery-ui-1.8.14.custom.min.js deleted file mode 100755 index d1949182..00000000 --- a/vendor/jquery-ui-1.8.14.custom.min.js +++ /dev/null @@ -1,100 +0,0 @@ -/*! - * jQuery UI 1.8.14 - * - * Copyright 2011, AUTHORS.txt (http://jqueryui.com/about) - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * http://docs.jquery.com/UI - */ -(function(c,j){function k(a,b){var d=a.nodeName.toLowerCase();if("area"===d){b=a.parentNode;d=b.name;if(!a.href||!d||b.nodeName.toLowerCase()!=="map")return false;a=c("img[usemap=#"+d+"]")[0];return!!a&&l(a)}return(/input|select|textarea|button|object/.test(d)?!a.disabled:"a"==d?a.href||b:b)&&l(a)}function l(a){return!c(a).parents().andSelf().filter(function(){return c.curCSS(this,"visibility")==="hidden"||c.expr.filters.hidden(this)}).length}c.ui=c.ui||{};if(!c.ui.version){c.extend(c.ui,{version:"1.8.14", -keyCode:{ALT:18,BACKSPACE:8,CAPS_LOCK:20,COMMA:188,COMMAND:91,COMMAND_LEFT:91,COMMAND_RIGHT:93,CONTROL:17,DELETE:46,DOWN:40,END:35,ENTER:13,ESCAPE:27,HOME:36,INSERT:45,LEFT:37,MENU:93,NUMPAD_ADD:107,NUMPAD_DECIMAL:110,NUMPAD_DIVIDE:111,NUMPAD_ENTER:108,NUMPAD_MULTIPLY:106,NUMPAD_SUBTRACT:109,PAGE_DOWN:34,PAGE_UP:33,PERIOD:190,RIGHT:39,SHIFT:16,SPACE:32,TAB:9,UP:38,WINDOWS:91}});c.fn.extend({_focus:c.fn.focus,focus:function(a,b){return typeof a==="number"?this.each(function(){var d=this;setTimeout(function(){c(d).focus(); -b&&b.call(d)},a)}):this._focus.apply(this,arguments)},scrollParent:function(){var a;a=c.browser.msie&&/(static|relative)/.test(this.css("position"))||/absolute/.test(this.css("position"))?this.parents().filter(function(){return/(relative|absolute|fixed)/.test(c.curCSS(this,"position",1))&&/(auto|scroll)/.test(c.curCSS(this,"overflow",1)+c.curCSS(this,"overflow-y",1)+c.curCSS(this,"overflow-x",1))}).eq(0):this.parents().filter(function(){return/(auto|scroll)/.test(c.curCSS(this,"overflow",1)+c.curCSS(this, -"overflow-y",1)+c.curCSS(this,"overflow-x",1))}).eq(0);return/fixed/.test(this.css("position"))||!a.length?c(document):a},zIndex:function(a){if(a!==j)return this.css("zIndex",a);if(this.length){a=c(this[0]);for(var b;a.length&&a[0]!==document;){b=a.css("position");if(b==="absolute"||b==="relative"||b==="fixed"){b=parseInt(a.css("zIndex"),10);if(!isNaN(b)&&b!==0)return b}a=a.parent()}}return 0},disableSelection:function(){return this.bind((c.support.selectstart?"selectstart":"mousedown")+".ui-disableSelection", -function(a){a.preventDefault()})},enableSelection:function(){return this.unbind(".ui-disableSelection")}});c.each(["Width","Height"],function(a,b){function d(f,g,m,n){c.each(e,function(){g-=parseFloat(c.curCSS(f,"padding"+this,true))||0;if(m)g-=parseFloat(c.curCSS(f,"border"+this+"Width",true))||0;if(n)g-=parseFloat(c.curCSS(f,"margin"+this,true))||0});return g}var e=b==="Width"?["Left","Right"]:["Top","Bottom"],h=b.toLowerCase(),i={innerWidth:c.fn.innerWidth,innerHeight:c.fn.innerHeight,outerWidth:c.fn.outerWidth, -outerHeight:c.fn.outerHeight};c.fn["inner"+b]=function(f){if(f===j)return i["inner"+b].call(this);return this.each(function(){c(this).css(h,d(this,f)+"px")})};c.fn["outer"+b]=function(f,g){if(typeof f!=="number")return i["outer"+b].call(this,f);return this.each(function(){c(this).css(h,d(this,f,true,g)+"px")})}});c.extend(c.expr[":"],{data:function(a,b,d){return!!c.data(a,d[3])},focusable:function(a){return k(a,!isNaN(c.attr(a,"tabindex")))},tabbable:function(a){var b=c.attr(a,"tabindex"),d=isNaN(b); -return(d||b>=0)&&k(a,!d)}});c(function(){var a=document.body,b=a.appendChild(b=document.createElement("div"));c.extend(b.style,{minHeight:"100px",height:"auto",padding:0,borderWidth:0});c.support.minHeight=b.offsetHeight===100;c.support.selectstart="onselectstart"in b;a.removeChild(b).style.display="none"});c.extend(c.ui,{plugin:{add:function(a,b,d){a=c.ui[a].prototype;for(var e in d){a.plugins[e]=a.plugins[e]||[];a.plugins[e].push([b,d[e]])}},call:function(a,b,d){if((b=a.plugins[b])&&a.element[0].parentNode)for(var e= -0;e0)return true;a[b]=1;d=a[b]>0;a[b]=0;return d},isOverAxis:function(a,b,d){return a>b&&a=9)&&!a.button)return this._mouseUp(a);if(this._mouseStarted){this._mouseDrag(a);return a.preventDefault()}if(this._mouseDistanceMet(a)&&this._mouseDelayMet(a))(this._mouseStarted=this._mouseStart(this._mouseDownEvent,a)!==false)?this._mouseDrag(a):this._mouseUp(a);return!this._mouseStarted},_mouseUp:function(a){b(document).unbind("mousemove."+this.widgetName,this._mouseMoveDelegate).unbind("mouseup."+this.widgetName,this._mouseUpDelegate);if(this._mouseStarted){this._mouseStarted= -false;a.target==this._mouseDownEvent.target&&b.data(a.target,this.widgetName+".preventClickEvent",true);this._mouseStop(a)}return false},_mouseDistanceMet:function(a){return Math.max(Math.abs(this._mouseDownEvent.pageX-a.pageX),Math.abs(this._mouseDownEvent.pageY-a.pageY))>=this.options.distance},_mouseDelayMet:function(){return this.mouseDelayMet},_mouseStart:function(){},_mouseDrag:function(){},_mouseStop:function(){},_mouseCapture:function(){return true}})})(jQuery); -;/* - * jQuery UI Draggable 1.8.14 - * - * Copyright 2011, AUTHORS.txt (http://jqueryui.com/about) - * Dual licensed under the MIT or GPL Version 2 licenses. - * http://jquery.org/license - * - * http://docs.jquery.com/UI/Draggables - * - * Depends: - * jquery.ui.core.js - * jquery.ui.mouse.js - * jquery.ui.widget.js - */ -(function(d){d.widget("ui.draggable",d.ui.mouse,{widgetEventPrefix:"drag",options:{addClasses:true,appendTo:"parent",axis:false,connectToSortable:false,containment:false,cursor:"auto",cursorAt:false,grid:false,handle:false,helper:"original",iframeFix:false,opacity:false,refreshPositions:false,revert:false,revertDuration:500,scope:"default",scroll:true,scrollSensitivity:20,scrollSpeed:20,snap:false,snapMode:"both",snapTolerance:20,stack:false,zIndex:false},_create:function(){if(this.options.helper== -"original"&&!/^(?:r|a|f)/.test(this.element.css("position")))this.element[0].style.position="relative";this.options.addClasses&&this.element.addClass("ui-draggable");this.options.disabled&&this.element.addClass("ui-draggable-disabled");this._mouseInit()},destroy:function(){if(this.element.data("draggable")){this.element.removeData("draggable").unbind(".draggable").removeClass("ui-draggable ui-draggable-dragging ui-draggable-disabled");this._mouseDestroy();return this}},_mouseCapture:function(a){var b= -this.options;if(this.helper||b.disabled||d(a.target).is(".ui-resizable-handle"))return false;this.handle=this._getHandle(a);if(!this.handle)return false;d(b.iframeFix===true?"iframe":b.iframeFix).each(function(){d('
      ').css({width:this.offsetWidth+"px",height:this.offsetHeight+"px",position:"absolute",opacity:"0.001",zIndex:1E3}).css(d(this).offset()).appendTo("body")});return true},_mouseStart:function(a){var b=this.options;this.helper= -this._createHelper(a);this._cacheHelperProportions();if(d.ui.ddmanager)d.ui.ddmanager.current=this;this._cacheMargins();this.cssPosition=this.helper.css("position");this.scrollParent=this.helper.scrollParent();this.offset=this.positionAbs=this.element.offset();this.offset={top:this.offset.top-this.margins.top,left:this.offset.left-this.margins.left};d.extend(this.offset,{click:{left:a.pageX-this.offset.left,top:a.pageY-this.offset.top},parent:this._getParentOffset(),relative:this._getRelativeOffset()}); -this.originalPosition=this.position=this._generatePosition(a);this.originalPageX=a.pageX;this.originalPageY=a.pageY;b.cursorAt&&this._adjustOffsetFromHelper(b.cursorAt);b.containment&&this._setContainment();if(this._trigger("start",a)===false){this._clear();return false}this._cacheHelperProportions();d.ui.ddmanager&&!b.dropBehaviour&&d.ui.ddmanager.prepareOffsets(this,a);this.helper.addClass("ui-draggable-dragging");this._mouseDrag(a,true);d.ui.ddmanager&&d.ui.ddmanager.dragStart(this,a);return true}, -_mouseDrag:function(a,b){this.position=this._generatePosition(a);this.positionAbs=this._convertPositionTo("absolute");if(!b){b=this._uiHash();if(this._trigger("drag",a,b)===false){this._mouseUp({});return false}this.position=b.position}if(!this.options.axis||this.options.axis!="y")this.helper[0].style.left=this.position.left+"px";if(!this.options.axis||this.options.axis!="x")this.helper[0].style.top=this.position.top+"px";d.ui.ddmanager&&d.ui.ddmanager.drag(this,a);return false},_mouseStop:function(a){var b= -false;if(d.ui.ddmanager&&!this.options.dropBehaviour)b=d.ui.ddmanager.drop(this,a);if(this.dropped){b=this.dropped;this.dropped=false}if((!this.element[0]||!this.element[0].parentNode)&&this.options.helper=="original")return false;if(this.options.revert=="invalid"&&!b||this.options.revert=="valid"&&b||this.options.revert===true||d.isFunction(this.options.revert)&&this.options.revert.call(this.element,b)){var c=this;d(this.helper).animate(this.originalPosition,parseInt(this.options.revertDuration, -10),function(){c._trigger("stop",a)!==false&&c._clear()})}else this._trigger("stop",a)!==false&&this._clear();return false},_mouseUp:function(a){this.options.iframeFix===true&&d("div.ui-draggable-iframeFix").each(function(){this.parentNode.removeChild(this)});d.ui.ddmanager&&d.ui.ddmanager.dragStop(this,a);return d.ui.mouse.prototype._mouseUp.call(this,a)},cancel:function(){this.helper.is(".ui-draggable-dragging")?this._mouseUp({}):this._clear();return this},_getHandle:function(a){var b=!this.options.handle|| -!d(this.options.handle,this.element).length?true:false;d(this.options.handle,this.element).find("*").andSelf().each(function(){if(this==a.target)b=true});return b},_createHelper:function(a){var b=this.options;a=d.isFunction(b.helper)?d(b.helper.apply(this.element[0],[a])):b.helper=="clone"?this.element.clone().removeAttr("id"):this.element;a.parents("body").length||a.appendTo(b.appendTo=="parent"?this.element[0].parentNode:b.appendTo);a[0]!=this.element[0]&&!/(fixed|absolute)/.test(a.css("position"))&& -a.css("position","absolute");return a},_adjustOffsetFromHelper:function(a){if(typeof a=="string")a=a.split(" ");if(d.isArray(a))a={left:+a[0],top:+a[1]||0};if("left"in a)this.offset.click.left=a.left+this.margins.left;if("right"in a)this.offset.click.left=this.helperProportions.width-a.right+this.margins.left;if("top"in a)this.offset.click.top=a.top+this.margins.top;if("bottom"in a)this.offset.click.top=this.helperProportions.height-a.bottom+this.margins.top},_getParentOffset:function(){this.offsetParent= -this.helper.offsetParent();var a=this.offsetParent.offset();if(this.cssPosition=="absolute"&&this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],this.offsetParent[0])){a.left+=this.scrollParent.scrollLeft();a.top+=this.scrollParent.scrollTop()}if(this.offsetParent[0]==document.body||this.offsetParent[0].tagName&&this.offsetParent[0].tagName.toLowerCase()=="html"&&d.browser.msie)a={top:0,left:0};return{top:a.top+(parseInt(this.offsetParent.css("borderTopWidth"),10)||0),left:a.left+(parseInt(this.offsetParent.css("borderLeftWidth"), -10)||0)}},_getRelativeOffset:function(){if(this.cssPosition=="relative"){var a=this.element.position();return{top:a.top-(parseInt(this.helper.css("top"),10)||0)+this.scrollParent.scrollTop(),left:a.left-(parseInt(this.helper.css("left"),10)||0)+this.scrollParent.scrollLeft()}}else return{top:0,left:0}},_cacheMargins:function(){this.margins={left:parseInt(this.element.css("marginLeft"),10)||0,top:parseInt(this.element.css("marginTop"),10)||0,right:parseInt(this.element.css("marginRight"),10)||0,bottom:parseInt(this.element.css("marginBottom"), -10)||0}},_cacheHelperProportions:function(){this.helperProportions={width:this.helper.outerWidth(),height:this.helper.outerHeight()}},_setContainment:function(){var a=this.options;if(a.containment=="parent")a.containment=this.helper[0].parentNode;if(a.containment=="document"||a.containment=="window")this.containment=[a.containment=="document"?0:d(window).scrollLeft()-this.offset.relative.left-this.offset.parent.left,a.containment=="document"?0:d(window).scrollTop()-this.offset.relative.top-this.offset.parent.top, -(a.containment=="document"?0:d(window).scrollLeft())+d(a.containment=="document"?document:window).width()-this.helperProportions.width-this.margins.left,(a.containment=="document"?0:d(window).scrollTop())+(d(a.containment=="document"?document:window).height()||document.body.parentNode.scrollHeight)-this.helperProportions.height-this.margins.top];if(!/^(document|window|parent)$/.test(a.containment)&&a.containment.constructor!=Array){a=d(a.containment);var b=a[0];if(b){a.offset();var c=d(b).css("overflow")!= -"hidden";this.containment=[(parseInt(d(b).css("borderLeftWidth"),10)||0)+(parseInt(d(b).css("paddingLeft"),10)||0),(parseInt(d(b).css("borderTopWidth"),10)||0)+(parseInt(d(b).css("paddingTop"),10)||0),(c?Math.max(b.scrollWidth,b.offsetWidth):b.offsetWidth)-(parseInt(d(b).css("borderLeftWidth"),10)||0)-(parseInt(d(b).css("paddingRight"),10)||0)-this.helperProportions.width-this.margins.left-this.margins.right,(c?Math.max(b.scrollHeight,b.offsetHeight):b.offsetHeight)-(parseInt(d(b).css("borderTopWidth"), -10)||0)-(parseInt(d(b).css("paddingBottom"),10)||0)-this.helperProportions.height-this.margins.top-this.margins.bottom];this.relative_container=a}}else if(a.containment.constructor==Array)this.containment=a.containment},_convertPositionTo:function(a,b){if(!b)b=this.position;a=a=="absolute"?1:-1;var c=this.cssPosition=="absolute"&&!(this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],this.offsetParent[0]))?this.offsetParent:this.scrollParent,f=/(html|body)/i.test(c[0].tagName);return{top:b.top+ -this.offset.relative.top*a+this.offset.parent.top*a-(d.browser.safari&&d.browser.version<526&&this.cssPosition=="fixed"?0:(this.cssPosition=="fixed"?-this.scrollParent.scrollTop():f?0:c.scrollTop())*a),left:b.left+this.offset.relative.left*a+this.offset.parent.left*a-(d.browser.safari&&d.browser.version<526&&this.cssPosition=="fixed"?0:(this.cssPosition=="fixed"?-this.scrollParent.scrollLeft():f?0:c.scrollLeft())*a)}},_generatePosition:function(a){var b=this.options,c=this.cssPosition=="absolute"&& -!(this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],this.offsetParent[0]))?this.offsetParent:this.scrollParent,f=/(html|body)/i.test(c[0].tagName),e=a.pageX,h=a.pageY;if(this.originalPosition){var g;if(this.containment){if(this.relative_container){g=this.relative_container.offset();g=[this.containment[0]+g.left,this.containment[1]+g.top,this.containment[2]+g.left,this.containment[3]+g.top]}else g=this.containment;if(a.pageX-this.offset.click.leftg[2])e=g[2]+this.offset.click.left;if(a.pageY-this.offset.click.top>g[3])h=g[3]+this.offset.click.top}if(b.grid){h=b.grid[1]?this.originalPageY+Math.round((h-this.originalPageY)/b.grid[1])*b.grid[1]:this.originalPageY;h=g?!(h-this.offset.click.topg[3])?h:!(h-this.offset.click.topg[2])?e:!(e-this.offset.click.left=0;i--){var j=c.snapElements[i].left,l=j+c.snapElements[i].width,k=c.snapElements[i].top,m=k+c.snapElements[i].height;if(j-e