diff --git a/README.md b/README.md index 22937fb..456af74 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ An interactive data visualization tool. VizWit uses a [JSON config file](https:/ interactive charts that cross-filter one another. It currently supports data hosted in a Socrata Open Data portal, which includes cities such as [Philadelphia](http://opendataphilly.org), [Chicago](https://data.cityofchicago.org/), [San Francisco](https://data.sfgov.org/) and many others. However, interactions with Socrata have been [abstracted](src/scripts/collections/socrata.js) to allow -for other data providers to be written (in theory). +for [other data providers](https://github.com/timwis/vizwit/wiki/Adding-a-provider) to be written. [![screencast](http://i.imgur.com/4gTXNFK.gif)](http://vizw.it/?gist=51db593dc0537d1a3f05) @@ -60,16 +60,17 @@ layout is generated by Gridstack. The application is compiled using Browserify. # How it works [vizwit.js](src/scripts/vizwit.js) is the primary chart-generating module. It takes a `container` selector and a `config` object and uses them to initialize a view (bar chart, map, table, etc.). The views are passed a `collection` and a `filteredCollection`, which are -identical and allow the view to query the data provider (Socrata). Views generate the chart/map/table/etc. and listen for user interactions +identical and allow the view to query the data provider. Views generate the chart/map/table/etc. and listen for user interactions on them. On an interaction, the view triggers the global event object with the filter (ie. `state=PA`), and all views that use the same dataset receive the event and use their `filteredCollection` to query the data provider with the filter passed through the event. By using a separate collection, VizWit can show the "filtered value" and the "original value" side-by-side. -The actual entry point is either [layout.js](src/scripts/layout.js) or [embed.js](src/scripts/embed.js). layout.js fetches a gist, reads -its configuration, and creates a layout on the page. Then for each chart in the configuration, it calls `init()` from vizwit.js as -described above. embed.js simply finds the parent container of the ` + + \ No newline at end of file diff --git a/src/embed-demo.html b/src/embed-demo.html index c1d317d..6bede83 100644 --- a/src/embed-demo.html +++ b/src/embed-demo.html @@ -13,7 +13,6 @@ - @@ -74,6 +73,6 @@

Hello, world!

- + \ No newline at end of file diff --git a/src/index.html b/src/index.html index 1c0a6b7..6ac3f5b 100644 --- a/src/index.html +++ b/src/index.html @@ -13,7 +13,6 @@ - @@ -42,6 +41,6 @@
- + \ No newline at end of file diff --git a/src/scripts/collections/basefields.js b/src/scripts/collections/basefields.js new file mode 100644 index 0000000..504778d --- /dev/null +++ b/src/scripts/collections/basefields.js @@ -0,0 +1,4 @@ +// A stub here for future proofing +var Backbone = require('backbone') + +module.exports = Backbone.Collection diff --git a/src/scripts/collections/baseprovider.js b/src/scripts/collections/baseprovider.js index 2844c55..3eb56b4 100644 --- a/src/scripts/collections/baseprovider.js +++ b/src/scripts/collections/baseprovider.js @@ -3,6 +3,7 @@ */ var Backbone = require('backbone') var _ = require('underscore') +var BaseFields = require('./basefields') var model = Backbone.Model.extend({ idAttribute: 'label' @@ -15,20 +16,28 @@ var enclose = function (val) { module.exports = Backbone.Collection.extend({ model: model, initialize: function (models, options) { - this.options = options || {} - if (!this.options.triggerField) this.options.triggerField = this.options.groupBy - if (!this.options.baseFilters) this.options.baseFilters = [] - if (!this.options.filters) this.options.filters = {} + options = options || {} + this.config = options.config || {} + if (!this.config.triggerField) this.config.triggerField = this.config.groupBy + if (!this.config.baseFilters) this.config.baseFilters = [] + if (!this.config.filters) this.config.filters = {} + + this.fieldsCache = options.fieldsCache || {} + // Check if fieldsCache already has a collection for this dataset, otherwise create one + if (!this.fieldsCache[this.config.dataset]) { + this.fieldsCache[this.config.dataset] = new this.fieldsCollection(null, this.config) // eslint-disable-line + } }, + fieldsCollection: BaseFields, setFilter: function (filter) { if (filter.expression) { - this.options.filters[filter.field] = filter + this.config.filters[filter.field] = filter } else { - delete this.options.filters[filter.field] + delete this.config.filters[filter.field] } }, getFilters: function (key) { - var filters = this.options.filters + var filters = this.config.filters if (key) { return filters[key] @@ -36,8 +45,8 @@ module.exports = Backbone.Collection.extend({ // If dontFilterSelf enabled, remove the filter this collection's triggerField // (don't do this if key provided since that's usually done to see if a filter is set // rather than to perform an actual filter query) - if (!_.isEmpty(filters) && this.options.dontFilterSelf) { - filters = _.omit(filters, this.options.triggerField) + if (!_.isEmpty(filters) && this.config.dontFilterSelf) { + filters = _.omit(filters, this.config.triggerField) } return _.values(filters) @@ -70,25 +79,28 @@ module.exports = Backbone.Collection.extend({ } }, getDataset: function () { - return this.options.dataset + return this.config.dataset }, getTriggerField: function () { - return this.options.triggerField + return this.config.triggerField + }, + getChannel: function () { + return this.config.dataset + '.filter' }, setSearch: function (newValue) { - this.options.search = newValue + this.config.search = newValue }, setDontFilterSelf: function (newValue) { - this.options.dontFilterSelf = newValue + this.config.dontFilterSelf = newValue }, setOrder: function (newValue) { - this.options.order = newValue + this.config.order = newValue }, setOffset: function (newValue) { - this.options.offset = newValue + this.config.offset = newValue }, setLimit: function (newValue) { - this.options.limit = newValue + this.config.limit = newValue }, unsetRecordCount: function () { this.recordCount = null diff --git a/src/scripts/collections/socrata-fields.js b/src/scripts/collections/socrata-fields.js index c8aa185..9a07c76 100644 --- a/src/scripts/collections/socrata-fields.js +++ b/src/scripts/collections/socrata-fields.js @@ -1,10 +1,11 @@ var Backbone = require('backbone') +var BaseFields = require('./basefields') var model = Backbone.Model.extend({ idAttribute: 'data' }) -module.exports = Backbone.Collection.extend({ +module.exports = BaseFields.extend({ typeMap: { calendar_date: 'date', number: 'num', @@ -14,27 +15,23 @@ module.exports = Backbone.Collection.extend({ model: model, comparator: 'position', initialize: function (models, options) { - this.options = options || {} + this.config = options || {} }, url: function () { return [ 'https://', - this.options.domain, + this.config.domain, '/api', '/views', - '/' + this.options.dataset, + '/' + this.config.dataset, '.json' ].join('') }, parse: function (response) { return response.columns.map(function (row, key) { - var titleForRow = row.name - if(!_.isEmpty(row.description)){ - titleForRow += ' ' - } return { data: row.fieldName, - title: titleForRow, + title: row.name, type: this.typeMap[row.renderTypeName] || this.typeMap.default, defaultContent: '', description: row.description diff --git a/src/scripts/collections/socrata.js b/src/scripts/collections/socrata.js index 1b1d3e2..37a70fd 100644 --- a/src/scripts/collections/socrata.js +++ b/src/scripts/collections/socrata.js @@ -1,48 +1,49 @@ var $ = require('jquery') var _ = require('underscore') -var Backbone = require('backbone') +var Promise = require('bluebird') var BaseProvider = require('./baseprovider') var soda = require('soda-js') +var SocrataFields = require('./socrata-fields') module.exports = BaseProvider.extend({ initialize: function (models, options) { BaseProvider.prototype.initialize.apply(this, arguments) - this.consumer = new soda.Consumer(this.options.domain) - this.countModel = new Backbone.Model() + this.consumer = new soda.Consumer(this.config.domain) }, + fieldsCollection: SocrataFields, url: function () { - var filters = this.options.baseFilters.concat(this.getFilters()) + var filters = this.config.baseFilters.concat(this.getFilters()) var query = this.consumer.query() - .withDataset(this.options.dataset) + .withDataset(this.config.dataset) // Aggregate & group by - if (this.options.valueField || this.options.aggregateFunction || this.options.groupBy) { + if (this.config.valueField || this.config.aggregateFunction || this.config.groupBy) { // If valueField specified, use it as the value - if (this.options.valueField) { - query.select(this.options.valueField + ' as value') + if (this.config.valueField) { + query.select(this.config.valueField + ' as value') // Otherwise use the aggregateFunction / aggregateField as the value } else { // If group by was specified but no aggregate function, use count by default - if (!this.options.aggregateFunction) this.options.aggregateFunction = 'count' + if (!this.config.aggregateFunction) this.config.aggregateFunction = 'count' // Aggregation - query.select(this.options.aggregateFunction + '(' + (this.options.aggregateField || '*') + ') as value') + query.select(this.config.aggregateFunction + '(' + (this.config.aggregateField || '*') + ') as value') } // Group by - if (this.options.groupBy) { - query.select(this.options.groupBy + ' as label') - .group(this.options.groupBy) + if (this.config.groupBy) { + query.select(this.config.groupBy + ' as label') + .group(this.config.groupBy) // Order by (only if there will be multiple results) - query.order(this.options.order || 'value desc') + query.order(this.config.order || 'value desc') } } else { // Offset - if (this.options.offset) query.offset(this.options.offset) + if (this.config.offset) query.offset(this.config.offset) // Order by - query.order(this.options.order || ':id') + query.order(this.config.order || ':id') } // Where @@ -55,39 +56,58 @@ module.exports = BaseProvider.extend({ } // Full text search - if (this.options.search) query.q(this.options.search) + if (this.config.search) query.q(this.config.search) // Limit - query.limit(this.options.limit || '5000') + query.limit(this.config.limit || '5000') return query.getURL() }, exportUrl: function () { - return this.url().replace(this.options.dataset + '.json', this.options.dataset + '.csv') + return this.url().replace(this.config.dataset + '.json', this.config.dataset + '.csv') }, getRecordCount: function () { var self = this + // If recordCount has already been fetched, return it as a promise + if (this.recordCount) { + return Promise.resolve(this.recordCount) + } else { + // Save current values + var oldAggregateFunction = this.config.aggregateFunction + var oldGroupBy = this.config.groupBy - // Save current values - var oldAggregateFunction = this.options.aggregateFunction - var oldGroupBy = this.options.groupBy - - // Change values in order to get the URL - this.options.aggregateFunction = 'count' - this.options.groupBy = null + // Change values in order to get the URL + this.config.aggregateFunction = 'count' + this.config.groupBy = null - // Get the URL - this.countModel.url = this.url() + // Get the URL + var url = this.url() - // Set the values back - this.options.aggregateFunction = oldAggregateFunction - this.options.groupBy = oldGroupBy + // Set the values back + this.config.aggregateFunction = oldAggregateFunction + this.config.groupBy = oldGroupBy - // If recordCount is already set, return it (as a deferred); otherwise fetch it - return self.recordCount ? ($.Deferred()).resolve(self.recordCount) : this.countModel.fetch() - .then(function (response) { + // technically returns a $.Deferred but bluebird throws a warning when + // returning a promise from within DataTables .ajax + return $.getJSON(url).then(function (response) { self.recordCount = response.length ? response[0].value : 0 return self.recordCount }) + } + }, + getFields: function () { + var fields = this.fieldsCache[this.config.dataset] + // TODO: Is there a better way to detect whether it's been fetched? + // (technically it could just have a 0 length after being fetched) + if (fields.length) { + return Promise.resolve(fields) + } else { + return new Promise(function (resolve, reject) { + fields.fetch({ + success: resolve, + error: reject + }) + }) + } } }) diff --git a/src/scripts/config/providers.js b/src/scripts/config/providers.js new file mode 100644 index 0000000..30c1967 --- /dev/null +++ b/src/scripts/config/providers.js @@ -0,0 +1,3 @@ +module.exports = { + socrata: require('../collections/socrata') +} diff --git a/src/scripts/layout.js b/src/scripts/layout.js index ceb79ed..6db5e95 100644 --- a/src/scripts/layout.js +++ b/src/scripts/layout.js @@ -1,88 +1,67 @@ -/* global global */ -var $ = global.jQuery = require('jquery') -var _ = global._ = require('underscore') +var $ = require('jquery') +var _ = require('underscore') var Backbone = require('backbone') -var deparam = require('node-jquery-deparam') -require('gridstack/dist/gridstack') var Header = require('./views/header') var vizwit = require('./vizwit') -var Gist = require('./collections/gist') var vent = _.clone(Backbone.Events) -var fields = {} +var fieldsCache = {} -var params = window.location.search.substr(1) ? deparam(window.location.search.substr(1)) : {} +module.exports = function (config, options) { + options = options || {} + if (!config.version || config.version !== '2') console.error('Wrong config version') -// If no gist specified, redirect to homepage -var redirect = function () { window.location.replace('http://vizwit.io') } -if (!params.gist) { - redirect() -} - -// Fetch gist -(new Gist(null, {id: params.gist})).fetch({ - success: function (collection, response, options) { - if (!collection.length) return console.error('No files in gist', params.gist) + // Render header + if (config.header) { + var header = new Header(config.header) + $(options.headerSelector).empty().append(header.render().el) - // If a file was provided, use that one; otherwise use the first file in the gist - var model = params.file && collection.get(params.file) ? collection.get(params.file) : collection.at(0) - var config = JSON.parse(model.get('content')) + // Update tag + if (config.header.title) { + var originalTitle = $('title').text() + $('title').text(config.header.title + ' - ' + originalTitle) + } + } - if (!config.version || config.version !== '2') return redirect() + var container = $(options.contentSelector) + var heightInterval = 60 // from gridstack.js + var current = {x: null, y: null} + var row - // Render header - if (config.header) { - var header = new Header(config.header) - $('#page-header').append(header.render().el) + container.empty() - // Update <title> tag - if (config.header.title) { - var originalTitle = $('title').text() - $('title').text(config.header.title + ' - ' + originalTitle) - } + config.cards.forEach(function (config) { + // If y suggests we're on a new row (including the first item), create a new row + if (config.y !== current.y) { + row = $('<div class="row"></div>') + container.append(row) + current.y = config.y + current.x = 0 } - var container = $('#page-content') - var heightInterval = 60 // from gridstack.js - var current = {x: null, y: null} - var row - - config.cards.forEach(function (config) { - // If y suggests we're on a new row (including the first item), create a new row - if (config.y !== current.y) { - row = $('<div class="row"></div>') - container.append(row) - current.y = config.y - current.x = 0 - } + var column = $('<div/>') - var column = $('<div/>') + // Add width class + column.addClass('col-sm-' + config.width) - // Add width class - column.addClass('col-sm-' + config.width) - - // If x is not the same as our current x position, add offset class - if (config.x !== current.x) { - column.addClass('col-sm-offset-' + (config.x - current.x)) - } - // Set height of new div - column.css('min-height', config.height * heightInterval) + // If x is not the same as our current x position, add offset class + if (config.x !== current.x) { + column.addClass('col-sm-offset-' + (config.x - current.x)) + } + // Set height of new div + column.css('min-height', config.height * heightInterval) - // Increment current.x to new starting position - current.x += config.width + // Increment current.x to new starting position + current.x += config.width - // Add the div to the current row - row.append(column) + // Add the div to the current row + row.append(column) - // Initialize vizwit on new div - vizwit.init(column, config.vizwit, { - vent: vent, - fields: fields - }) + // Initialize vizwit on new div + vizwit.init(column, config.vizwit, { + vent: vent, + fieldsCache: fieldsCache }) - }, - error: function () { - console.error('Error fetching gist', params.gist) - } -}) + }) +} diff --git a/src/scripts/providers.js b/src/scripts/providers.js deleted file mode 100644 index 453ce91..0000000 --- a/src/scripts/providers.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - socrata: { - Collection: require('./collections/socrata'), - Fields: require('./collections/socrata-fields') - } -} diff --git a/src/scripts/views/card.js b/src/scripts/views/card.js index b5a2cf6..42485f8 100644 --- a/src/scripts/views/card.js +++ b/src/scripts/views/card.js @@ -20,7 +20,6 @@ module.exports = Backbone.View.extend({ initialize: function (options) { options = options || {} this.config = options.config || {} - this.fields = options.fields || {} this.template = Template _.bindAll(this, 'onClickRemoveFilter') @@ -63,13 +62,17 @@ module.exports = Backbone.View.extend({ var self = this var filters = this.filteredCollection ? this.filteredCollection.getFilters() : this.collection.getFilters() - var parsedFilters = _.map(filters, function (filter) { - return { - field: filter.field, - expression: self.parseExpression(filter.field, filter.expression) - } + this.collection.getFields().then(function (fieldsCollection) { + var parsedFilters = _.map(filters, function (filter) { + var match = fieldsCollection.get(filter.field) + var fieldName = match ? match.get('title') : filter.field + return { + field: filter.field, + expression: self.parseExpression(fieldName, filter.expression) + } + }) + self.$('.filters').empty().append(FiltersTemplate(parsedFilters)).toggle(parsedFilters.length ? true : false) // eslint-disable-line }) - this.$('.filters').empty().append(FiltersTemplate(parsedFilters)).toggle(parsedFilters.length ? true : false) // eslint-disable-line }, parseExpression: function (field, expression) { if (expression.type === 'and' || expression.type === 'or') { @@ -79,9 +82,8 @@ module.exports = Backbone.View.extend({ this.parseExpression(field, expression.value[1]) ] } else { - var match = this.fields.get(field) return [ - match ? match.get('title') : field, + field, operatorMap[expression.type] || expression.type, expression.label || expression.value ] diff --git a/src/scripts/views/config/pie.config.js b/src/scripts/views/config/pie.config.js new file mode 100644 index 0000000..1319dba --- /dev/null +++ b/src/scripts/views/config/pie.config.js @@ -0,0 +1,49 @@ +module.exports = { + type: 'pie', + theme: 'light', + titleField: 'label', + valueField: 'value', + pulledField: 'pulled', + innerRadius: '40%', + groupPercent: 1, + balloonFunction: function (item, formattedText) { + var content = '<b>' + item.title + '</b><br>' + + 'Total: ' + item.value.toLocaleString() + ' (' + parseFloat(item.percents.toFixed(2)) + '%)' + if (item.dataContext.filteredValue !== undefined) { + content += '<br>Filtered Amount: ' + (+item.dataContext.filteredValue).toLocaleString() + } + return content + }, + labelFunction: function (item, formattedText) { + return item.title.length > 15 ? item.title.substr(0, 15) + '…' : item.title + }, + balloon: {}, + autoMargins: false, + marginTop: 0, + marginBottom: 0, + marginLeft: 0, + marginRight: 0, + pullOutRadius: '10%', + pullOutOnlyOne: true, + labelRadius: 1, + pieAlpha: 0.8, + hideLabelsPercent: 5, + creditsPosition: 'bottom-right', + startDuration: 0, + addClassNames: true, + responsive: { + enabled: true, + addDefaultRules: false, + rules: [ + { + maxWidth: 450, + overrides: { + pullOutRadius: '10%', + titles: { + enabled: false + } + } + } + ] + } +} diff --git a/src/scripts/views/pie.js b/src/scripts/views/pie.js index 38a1fb5..46dd83f 100644 --- a/src/scripts/views/pie.js +++ b/src/scripts/views/pie.js @@ -9,69 +9,53 @@ var LoaderOff = require('../util/loader').off ;require('amcharts3/amcharts/plugins/responsive/responsive') var AmCharts = window.AmCharts AmCharts.path = './' +var config = require('./config/pie.config') + +var isSliceSelected = function (filteredCollection, sliceTitle) { + var selfFilter = filteredCollection.getFilters(filteredCollection.getTriggerField()) + return selfFilter && (selfFilter.expression.label || selfFilter.expression.value) === sliceTitle +} + +var formatChartData = function (collection, filteredCollection) { + var chartData = [] + var isFiltered = filteredCollection.getFilters().length + var selfFilter = filteredCollection.getFilters(filteredCollection.getTriggerField()) + + // Map collection(s) into format expected by chart library + collection.forEach(function (model) { + var data = { + label: model.get('label') + '', // ensure it's a string + value: model.get('value') + } + // If the filtered collection has been fetched, find the corresponding record and put it in another series + if (isFiltered) { + var filteredCollectionMatch = filteredCollection.get(data.label) + // Push a record even if there's no match so we don't align w/ the wrong slice in the other collection + data.filteredValue = filteredCollectionMatch ? filteredCollectionMatch.get('value') : 0 + } + // If this slice is selected, set it to be pulled + if (selfFilter && selfFilter.expression.value === data.label) { + data.pulled = true + } + + chartData.push(data) + }) + return chartData +} +var isOtherSlice = function (dataItem) { + return _.isEmpty(dataItem.dataContext) +} module.exports = Card.extend({ - settings: { - chart: { - type: 'pie', - theme: 'light', - titleField: 'label', - valueField: 'value', - pulledField: 'pulled', - innerRadius: '40%', - groupPercent: 1, - balloonFunction: function (item, formattedText) { - var content = '<b>' + item.title + '</b><br>' + - 'Total: ' + item.value.toLocaleString() + ' (' + parseFloat(item.percents.toFixed(2)) + '%)' - if (item.dataContext.filteredValue !== undefined) { - content += '<br>Filtered Amount: ' + (+item.dataContext.filteredValue).toLocaleString() - } - return content - }, - labelFunction: function (item, formattedText) { - return item.title.length > 15 ? item.title.substr(0, 15) + '…' : item.title - }, - balloon: {}, - autoMargins: false, - marginTop: 0, - marginBottom: 0, - marginLeft: 0, - marginRight: 0, - pullOutRadius: '10%', - pullOutOnlyOne: true, - labelRadius: 1, - pieAlpha: 0.8, - hideLabelsPercent: 5, - creditsPosition: 'bottom-right', - startDuration: 0, - addClassNames: true, - responsive: { - enabled: true, - addDefaultRules: false, - rules: [ - { - maxWidth: 450, - overrides: { - pullOutRadius: '10%', - titles: { - enabled: false - } - } - } - ] - } - } - }, initialize: function (options) { Card.prototype.initialize.apply(this, arguments) // Save options to view - options = options || {} this.vent = options.vent || null this.filteredCollection = options.filteredCollection || null // Listen to vent filters - this.listenTo(this.vent, this.collection.getDataset() + '.filter', this.onFilter) + this.listenTo(this.vent, this.collection.getChannel(), this.onFilter) // Listen to collection this.listenTo(this.collection, 'sync', this.render) @@ -90,91 +74,63 @@ module.exports = Card.extend({ }, render: function () { // Initialize chart - var config = $.extend(true, {}, this.settings.chart) - config.dataProvider = this.formatChartData() + var configCopy = $.extend(true, {}, config) // TODO: Why do we need a copy again? + configCopy.dataProvider = formatChartData(this.collection, this.filteredCollection) if (this.filteredCollection.getFilters().length) { - config.valueField = 'filteredValue' + configCopy.valueField = 'filteredValue' } // If "other" slice is selected, set other slice to be pulled out - var otherSliceTitle = config.groupedTitle || 'Other' - var filter = this.filteredCollection.getFilters(this.filteredCollection.getTriggerField()) - if (filter && (filter.expression.label || filter.expression.value) === otherSliceTitle) { - config.groupedPulled = true + var otherSliceTitle = configCopy.groupedTitle || 'Other' + if (isSliceSelected(this.filteredCollection, otherSliceTitle)) { + configCopy.groupedPulled = true } - this.chart = AmCharts.makeChart(this.$('.card-content').get(0), config) + var container = this.$('.card-content').get(0) + this.chart = AmCharts.makeChart(container, configCopy) this.chart.addListener('clickSlice', this.onClickSlice) }, - formatChartData: function () { - var self = this - var chartData = [] - var filter = this.filteredCollection.getFilters(this.filteredCollection.getTriggerField()) - - // Map collection(s) into format expected by chart library - this.collection.forEach(function (model) { - var label = model.get('label') + '' // ensure it's a string - var data = { - label: label, - value: model.get('value') - } - // If the filtered collection has been fetched, find the corresponding record and put it in another series - if (self.filteredCollection.getFilters().length) { - var match = self.filteredCollection.get(label) - // Push a record even if there's no match so we don't align w/ the wrong bar in the other collection - data.filteredValue = match ? match.get('value') : 0 - } - // If this slice is selected, set it to be pulled - if (filter && filter.expression.value === label) { - data.pulled = true - } - - chartData.push(data) - }) - return chartData - }, onClickSlice: function (data) { var category = data.dataItem.title + var triggerField = this.filteredCollection.getTriggerField() // If already selected, clear the filter - var filter = this.filteredCollection.getFilters(this.filteredCollection.getTriggerField()) - if (filter && (filter.expression.value === category || filter.expression.label === category)) { - this.vent.trigger(this.collection.getDataset() + '.filter', { - field: this.filteredCollection.getTriggerField() - }) + var selfFilter = this.filteredCollection.getFilters(triggerField) + if (selfFilter && (selfFilter.expression.label || selfFilter.expression.value) === category) { + this._fireFilterEvent() // Otherwise, add the filter } else { // If "Other" slice, get all of the currently displayed categories and send then as a NOT IN() query - if (_.isEmpty(data.dataItem.dataContext)) { - var shownCategories = [] - data.chart.chartData.forEach(function (item) { - if (item.title !== category) { - shownCategories.push(item.title) - } + if (isOtherSlice(data.dataItem)) { + var shownCategories = _.pluck(data.chart.chartData, 'title').filter(function (title) { + return title !== category }) + var otherSliceTitle = this.chart.groupedTitle || 'Other' - this.vent.trigger(this.collection.getDataset() + '.filter', { - field: this.collection.getTriggerField(), - expression: { - type: 'not in', - value: shownCategories, - label: this.config.groupedTitle || 'Other' - } + this._fireFilterEvent({ + type: 'not in', + value: shownCategories, + label: otherSliceTitle }) - // Otherwise fire a normal = query + // Otherwise fire a normal equals query } else { - this.vent.trigger(this.collection.getDataset() + '.filter', { - field: this.collection.getTriggerField(), - expression: { - type: '=', - value: category - } + this._fireFilterEvent({ + type: '=', + value: category }) } } }, + _fireFilterEvent: function (expression) { + var channel = this.collection.getChannel() + var triggerField = this.filteredCollection.getTriggerField() + this.vent.trigger(channel, { + field: triggerField, + expression: expression + }) + }, // When a chart has been filtered onFilter: function (data) { // Add the filter to the filtered collection and fetch it with the filter diff --git a/src/scripts/views/table.js b/src/scripts/views/table.js index da542f5..9cb27b6 100644 --- a/src/scripts/views/table.js +++ b/src/scripts/views/table.js @@ -6,11 +6,30 @@ require('datatables') require('datatables/media/js/dataTables.bootstrap') require('bootstrap/js/tooltip') +var hideColumns = function (columns, columnsToHide) { + if (!_.isArray(columnsToHide)) { + return columns + } + return _.reject(columns, function (column) { + return _.contains(columnsToHide, column.data) + }) +} + +var tooltip = function (contents) { + return '<span class="fa fa-info-circle" data-toggle="tooltip" data-placement="top" title="' + contents + '"></span>' +} + +var addDescriptionToTitle = function (column) { + if (column.description) { + column.title += ' ' + tooltip(column.description) + } + return column +} + module.exports = Card.extend({ initialize: function (options) { Card.prototype.initialize.apply(this, arguments) - options = options || {} this.vent = options.vent || null // Listen to vent filters @@ -20,92 +39,71 @@ module.exports = Card.extend({ this.listenTo(this.collection, 'request', LoaderOn) this.listenTo(this.collection, 'sync', LoaderOff) - // If columns were defined in the config, go straight to render - // otherwise, fetch columns through the metadata model - if (this.config.columns) { - this.render() - } else { - this.listenTo(this.fields, 'sync', this.render) - } + this.render() }, render: function () { - var self = this // If table is already initialized, clear it and add the collection to it if (this.table) { - this.$el.DataTable().clear().rows.add(this.collection.toJSON()).draw() + var initializedTable = this.$el.DataTable() + initializedTable.clear() + initializedTable.rows.add(this.collection.toJSON()).draw() // Otherwise, initialize the table } else { - // Map the array of columns to the expected format - var columns - - if (this.config.columns) { - columns = this.config.columns.map(function (column) { - if (typeof column === 'string') { - return { - data: column, - title: column, - defaultContent: '' - } - } else if (typeof column === 'object') { - column.defaultContent = '' - return column - } + this.collection.getFields().then(_.bind(function (fieldsCollection) { + var columns = fieldsCollection.toJSON() + + columns = hideColumns(columns, this.config.columnsToHide) + + columns = columns.map(addDescriptionToTitle) + + // Initialize the table + var container = this.$('.card-content table') + this.table = container.DataTable({ + columns: columns, + order: [], + scrollX: true, + serverSide: true, + ajax: _.bind(this.dataTablesAjax, this) }) - } else { - columns = this.fields.toJSON() + + this.activateTooltips(container) + }, this)) + } + }, + // Adjust collection using table state, then pass off to collection.fetch with datatables callback + dataTablesAjax: function (tableState, dataTablesCallback, dataTablesSettings) { + this.collection.setSearch(tableState.search.value ? tableState.search.value : null) + + // Get record count first because it needs to be passed into the collection.fetch callback + this.collection.getRecordCount().then(_.bind(function (recordCount) { + if (!this.recordsTotal) { + this.recordsTotal = recordCount } + var recordsTotal = this.recordsTotal // for use in callback below + + this.collection.setOffset(tableState.start || 0) + this.collection.setLimit(tableState.length || 25) - if (_.isArray(this.config.columnsToHide)) { - columns = _.reject(columns, function (column) { - return _.contains(this.config.columnsToHide, column.data) - }, this) + if (tableState.order.length) { + this.collection.setOrder(tableState.columns[tableState.order[0].column].data + ' ' + tableState.order[0].dir) } - // Initialize the table - this.table = this.$('.card-content table').DataTable({ - columns: columns, - order: [], - scrollX: true, - serverSide: true, - ajax: function (data, callback, settings) { - self.collection.setSearch(data.search.value ? data.search.value : null) - - - self.collection.getRecordCount().done(function (recordCount) { - self.recordsTotal = self.recordsTotal || recordCount - self.collection.setOffset(data.start || 0) - self.collection.setLimit(data.length || 25) - if (data.order.length) { - self.collection.setOrder(data.columns[data.order[0].column].data + ' ' + data.order[0].dir) - } - self.collection.fetch({ - success: function (collection, response, options) { - tableData = _.map(collection.toJSON(), function(item){ - item = _.each(item, function(v,k){ - if(typeof v === 'object'){ - item[k] = JSON.stringify(v) - } - }) - return item - }) - - callback({ - data: tableData, - recordsTotal: self.recordsTotal, - recordsFiltered: recordCount - }) - } - }) + this.collection.fetch({ + success: function (collection, response, options) { + dataTablesCallback({ + data: collection.toJSON(), + recordsTotal: recordsTotal, + recordsFiltered: recordCount }) } }) - } - - // Initialize bootstrap-powered tooltips - jQuery('.dataTables_scrollHeadInner th span[data-toggle="tooltip"]').tooltip({ - container : 'body' - }); - + }, this)) + }, + // Initialize bootstrap-powered tooltips for column descriptions + activateTooltips: function (container) { + this.$('.dataTables_scrollHeadInner th span[data-toggle="tooltip"]', container).tooltip({ + container: 'body' + }) }, // When another chart is filtered, filter this collection onFilter: function (data) { diff --git a/src/scripts/vizwit-editor.js b/src/scripts/vizwit-editor.js new file mode 100644 index 0000000..726f40c --- /dev/null +++ b/src/scripts/vizwit-editor.js @@ -0,0 +1,28 @@ +var _ = require('underscore') +var split = require('split.js') +var ace = require('brace') +;require('brace/mode/json') +var layout = require('./layout') + +var sampleData = require('../data/sample.json') + +var layoutOptions = { + headerSelector: '#page-header', + contentSelector: '#page-content' +} + +split(['#editor', '#preview'], {sizes: [40, 60]}) + +var editor = ace.edit('editor') +var session = editor.getSession() +session.setMode('ace/mode/json') +editor.setValue(JSON.stringify(sampleData, null, 2), -1) + +var refresh = function () { + var input = JSON.parse(editor.getValue()) + layout(input, layoutOptions) +} + +session.on('change', _.debounce(refresh, 300)) + +refresh() diff --git a/src/scripts/embed.js b/src/scripts/vizwit-embed.js similarity index 87% rename from src/scripts/embed.js rename to src/scripts/vizwit-embed.js index debd366..cd0c523 100644 --- a/src/scripts/embed.js +++ b/src/scripts/vizwit-embed.js @@ -4,14 +4,14 @@ var Backbone = require('backbone') var vizwit = require('./vizwit') var vent = _.clone(Backbone.Events) -var fields = {} +var fieldsCache = {} $(document).ready(function () { $('script.vizwit').each(function (scriptTag) { var config = JSON.parse($(this).html()) vizwit.init($(this).parent(), config, { vent: vent, - fields: fields + fieldsCache: fieldsCache }) }) }) diff --git a/src/scripts/vizwit-loader.js b/src/scripts/vizwit-loader.js new file mode 100644 index 0000000..c6135dd --- /dev/null +++ b/src/scripts/vizwit-loader.js @@ -0,0 +1,40 @@ +var $ = require('jquery') +var deparam = require('node-jquery-deparam') +var Gist = require('./collections/gist') +var layout = require('./layout') + +var params = window.location.search.substr(1) ? deparam(window.location.search.substr(1)) : {} +var pathToFiles = 'data/' // should include trailing slash + +var layoutOptions = { + headerSelector: '#page-header', + contentSelector: '#page-content' +} + +// If gist ID specified, fetch it using github's API +if (params.gist) { + (new Gist(null, {id: params.gist})).fetch({ + success: function (collection, response, options) { + if (!collection.length) return console.error('No files in gist', params.gist) + + // If a file was provided, use that one; otherwise use the first file in the gist + var model = params.file && collection.get(params.file) ? collection.get(params.file) : collection.at(0) + var config = JSON.parse(model.get('content')) + + layout(config, layoutOptions) + }, + error: function () { + console.error('Error fetching gist', params.gist) + } + }) +// If file specified, load it from the files directory +} else if (params.file) { + $.getJSON(pathToFiles + params.file, function (data) { + layout(data, layoutOptions) + }).fail(function () { + console.error('Error loading file', params.file) + }) +// If no config specified, redirect to homepage +} else { + window.location.replace('http://vizwit.io') +} diff --git a/src/scripts/vizwit.js b/src/scripts/vizwit.js index 71d41fc..84a62d7 100644 --- a/src/scripts/vizwit.js +++ b/src/scripts/vizwit.js @@ -2,7 +2,7 @@ var _ = require('underscore') var Backbone = require('backbone') -var Providers = require('./providers') +var Providers = require('./config/providers') var GeoJSON = require('./collections/geojson') var Bar = require('./views/bar') @@ -16,22 +16,22 @@ exports.init = function (container, config, opts) { // If globals weren't passed, create them within this scope opts = opts || {} opts.vent = opts.vent || _.clone(Backbone.Events) - opts.fields = opts.fields || {} + opts.fieldsCache = opts.fieldsCache || {} // Get provider if (!config.provider) config.provider = 'socrata' // set default for backwards compatibility - var provider = Providers[config.provider.toLowerCase()] - if (!provider) console.error('Unrecognized provider %s', config.provider) + var Provider = Providers[config.provider.toLowerCase()] + if (!Provider) console.error('Unrecognized provider %s', config.provider) // Initialize collection - var collection = new provider.Collection(null, config) - var filteredCollection = new provider.Collection(null, config) - - // If we haven't already created a fields collection for this dataset, create one - if (opts.fields[config.dataset] === undefined) { - opts.fields[config.dataset] = new provider.Fields(null, config) - opts.fields[config.dataset].fetch() - } + var collection = new Provider(null, { + config: config, + fieldsCache: opts.fieldsCache + }) + var filteredCollection = new Provider(null, { + config: config, + fieldsCache: opts.fieldsCache + }) // Initialize view switch (config.chartType) { @@ -41,7 +41,6 @@ exports.init = function (container, config, opts) { el: container, collection: collection, filteredCollection: filteredCollection, - fields: opts.fields[config.dataset], vent: opts.vent }) break @@ -51,7 +50,6 @@ exports.init = function (container, config, opts) { el: container, collection: collection, filteredCollection: filteredCollection, - fields: opts.fields[config.dataset], vent: opts.vent }) break @@ -63,7 +61,6 @@ exports.init = function (container, config, opts) { el: container, collection: collection, filteredCollection: filteredCollection, - fields: opts.fields[config.dataset], vent: opts.vent }) break @@ -72,7 +69,6 @@ exports.init = function (container, config, opts) { config: config, el: container, collection: collection, - fields: opts.fields[config.dataset], vent: opts.vent }) break @@ -83,7 +79,6 @@ exports.init = function (container, config, opts) { collection: collection, boundaries: new GeoJSON(null, config), filteredCollection: filteredCollection, - fields: opts.fields[config.dataset], vent: opts.vent }) break @@ -93,7 +88,6 @@ exports.init = function (container, config, opts) { el: container, collection: collection, filteredCollection: filteredCollection, - fields: opts.fields[config.dataset], vent: opts.vent }) break diff --git a/src/styles/editor.css b/src/styles/editor.css new file mode 100644 index 0000000..80c0063 --- /dev/null +++ b/src/styles/editor.css @@ -0,0 +1,37 @@ +html, body, .row { + height: 100%; +} + +body { + box-sizing: border-box; + margin: 0; +} + +.gutter { + background-color: #eee; + background-repeat: no-repeat; + background-position: 50%; +} + +.gutter.gutter-horizontal { + background-image: url('https://raw.githubusercontent.com/nathancahill/Split.js/master/grips/vertical.png'); + cursor: ew-resize; +} + +.gutter.gutter-vertical { + background-image: url('https://raw.githubusercontent.com/nathancahill/Split.js/master/grips/horizontal.png'); + cursor: ns-resize; +} + +.split { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; +} + +.split, .gutter.gutter-horizontal { + height: 100%; + float: left; +} \ No newline at end of file diff --git a/test/fixtures/fields.json b/test/fixtures/fields.json new file mode 100644 index 0000000..a2961ef --- /dev/null +++ b/test/fixtures/fields.json @@ -0,0 +1,14 @@ +[ + { + "data": "foo", + "title": "Foo", + "type": "string", + "defaultContent": "" + }, + { + "data": "bar", + "title": "Boo", + "type": "string", + "defaultContent": "" + } +] \ No newline at end of file diff --git a/test/spec/collections/provider.js b/test/spec/collections/provider.js new file mode 100644 index 0000000..5731479 --- /dev/null +++ b/test/spec/collections/provider.js @@ -0,0 +1,48 @@ +var test = require('tape') +var BaseFields = require('../../../src/scripts/collections/basefields') +var providers = require('../../../src/scripts/config/providers') + +var sampleFieldsData = require('../../fixtures/fields.json') + +for (var i in providers) { + var Provider = providers[i] + + test('provider: ' + i + ': initialize sets defaults', function (t) { + t.plan(3) + var config = {groupBy: 'foo'} + var provider = new Provider(null, {config: config}) + t.deepEqual(provider.config.baseFilters, []) + t.deepEqual(provider.config.filters, {}) + t.equal(provider.config.triggerField, 'foo') + }) + + test('provider: ' + i + ': fields collection is instanceof BaseFields', function (t) { + t.plan(1) + var provider = new Provider() + t.ok(provider instanceof BaseFields) + }) + + test('provider: ' + i + ': getRecordCount returns count as promise', function (t) { + t.plan(2) + var provider = new Provider() + provider.recordCount = 50 + var recordCount = provider.getRecordCount() + t.equal(typeof recordCount.then, 'function') + recordCount.then(function (total) { + t.equal(total, 50) + }) + }) + + test('provider: ' + i + ': getFields returns fields as promise', function (t) { + t.plan(2) + var config = {dataset: 'foo'} + var provider = new Provider(null, {config: config}) + var Fields = provider.fieldsCollection + provider.fieldsCache.foo = new Fields(sampleFieldsData) // give it a preloaded fields collection + var fields = provider.getFields() + t.equal(typeof fields.then, 'function') + fields.then(function (collection) { + t.equal(collection.length, 2) + }) + }) +} diff --git a/test/spec/collections/socrata.js b/test/spec/collections/socrata.js index da0234c..90cb7e8 100644 --- a/test/spec/collections/socrata.js +++ b/test/spec/collections/socrata.js @@ -24,10 +24,11 @@ var respond = function (server, data) { */ test('socrata: query: should construct base url', function (t) { t.plan(1) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx' - }) + } + var collection = new Socrata(null, {config: config}) var url = collection.url().split('?')[0] t.equal(url, 'https://data.cityofchicago.org/resource/xqx5-8hwx.json') @@ -35,10 +36,11 @@ test('socrata: query: should construct base url', function (t) { test('socrata: query: should construct export url', function (t) { t.plan(1) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx' - }) + } + var collection = new Socrata(null, {config: config}) var url = collection.exportUrl().split('?')[0] t.equal(url, 'https://data.cityofchicago.org/resource/xqx5-8hwx.csv', 'csv suffix') @@ -46,10 +48,11 @@ test('socrata: query: should construct export url', function (t) { test('socrata: query: should order by :id by default', function (t) { t.plan(1) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx' - }) + } + var collection = new Socrata(null, {config: config}) var params = deparam(collection.url().split('?')[1]) t.equal(params.$order, ':id asc') @@ -57,11 +60,12 @@ test('socrata: query: should order by :id by default', function (t) { test('socrata: query: should allow order override', function (t) { t.plan(1) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx', order: 'foo desc' - }) + } + var collection = new Socrata(null, {config: config}) var params = deparam(collection.url().split('?')[1]) t.equal(params.$order, 'foo desc') @@ -69,12 +73,13 @@ test('socrata: query: should allow order override', function (t) { test('socrata: query: should paginate', function (t) { t.plan(2) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx', limit: 50, offset: 100 - }) + } + var collection = new Socrata(null, {config: config}) var params = deparam(collection.url().split('?')[1]) t.equal(params.$limit, 50) @@ -83,10 +88,11 @@ test('socrata: query: should paginate', function (t) { test('socrata: query: should free text search', function (t) { t.plan(1) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx' - }) + } + var collection = new Socrata(null, {config: config}) collection.setSearch('foo') @@ -99,11 +105,12 @@ test('socrata: query: should free text search', function (t) { */ test('socrata: aggregation: should default to count(*) for basic group by', function (t) { t.plan(2) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx', groupBy: 'license_type' - }) + } + var collection = new Socrata(null, {config: config}) var params = deparam(collection.url().split('?')[1]) t.equal(params.$select, 'count(*) as value, license_type as label') @@ -112,13 +119,14 @@ test('socrata: aggregation: should default to count(*) for basic group by', func test('socrata: aggregation: should allow other aggregation functions', function (t) { t.plan(2) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx', groupBy: 'license_type', aggregateFunction: 'sum', aggregateField: 'license_cost' - }) + } + var collection = new Socrata(null, {config: config}) var params = deparam(collection.url().split('?')[1]) t.equal(params.$select, 'sum(license_cost) as value, license_type as label') @@ -127,12 +135,13 @@ test('socrata: aggregation: should allow other aggregation functions', function test('socrata: aggregation: should allow aggregation without group by', function (t) { t.plan(3) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx', aggregateFunction: 'sum', aggregateField: 'license_cost' - }) + } + var collection = new Socrata(null, {config: config}) var params = deparam(collection.url().split('?')[1]) t.ok(params.$select, 'should have $select property') @@ -142,12 +151,13 @@ test('socrata: aggregation: should allow aggregation without group by', function test('socrata: aggregation: should allow override of order when grouping', function (t) { t.plan(2) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx', groupBy: 'license_type', order: 'label' - }) + } + var collection = new Socrata(null, {config: config}) var params = deparam(collection.url().split('?')[1]) t.equal(params.$select, 'count(*) as value, license_type as label') @@ -159,10 +169,11 @@ test('socrata: aggregation: should allow override of order when grouping', funct */ test('socrata: filters: should set = filter', function (t) { t.plan(1) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx' - }) + } + var collection = new Socrata(null, {config: config}) collection.setFilter({ field: 'license_description', @@ -178,10 +189,11 @@ test('socrata: filters: should set = filter', function (t) { test('socrata: filters: should set multiple filters', function (t) { t.plan(1) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx' - }) + } + var collection = new Socrata(null, {config: config}) collection.setFilter({ field: 'license_description', @@ -205,10 +217,11 @@ test('socrata: filters: should set multiple filters', function (t) { test('socrata: filters: should set AND filters', function (t) { t.plan(1) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx' - }) + } + var collection = new Socrata(null, {config: config}) collection.setFilter({ field: 'code', @@ -233,10 +246,11 @@ test('socrata: filters: should set AND filters', function (t) { test('socrata: filters: should set IN filters', function (t) { t.plan(1) - var collection = new Socrata(null, { + var config = { domain: 'data.cityofchicago.org', dataset: 'xqx5-8hwx' - }) + } + var collection = new Socrata(null, {config: config}) collection.setFilter({ field: 'code', @@ -264,13 +278,14 @@ test('socrata: filters: should set IN filters', function (t) { test('socrata: fetch: should get correct number of features', function (t) { t.plan(1) var fixtures = setup() - var collection = new Socrata(null, { + var config = { 'title': 'License Description', 'chartType': 'bar', 'domain': 'data.cityofchicago.org', 'dataset': 'xqx5-8hwx', 'groupBy': 'license_description' - }) + } + var collection = new Socrata(null, {config: config}) collection.fetch() respond(fixtures.server, sampleData) t.equal(collection.length, 132) diff --git a/test/spec/views/bar.js b/test/spec/views/bar.js index 6e7f448..e84b5b0 100644 --- a/test/spec/views/bar.js +++ b/test/spec/views/bar.js @@ -1,10 +1,7 @@ var test = require('tape') var sinon = require('sinon') -var Backbone = require('backbone') -var _ = require('underscore') var $ = require('jquery') var Socrata = require('../../../src/scripts/collections/socrata') -var SocrataFields = require('../../../src/scripts/collections/socrata-fields') var Bar = require('../../../src/scripts/views/bar') var sampleData = require('../../fixtures/business-licenses.json') @@ -23,10 +20,8 @@ var setup = function () { view: new Bar({ config: config, el: $('<div/>', {width: 700, height: 500}).appendTo('body'), - collection: new Socrata(null, config), - filteredCollection: new Socrata(null, config), - fields: new SocrataFields(), - vent: _.clone(Backbone.Events) + collection: new Socrata(null, {config: config}), + filteredCollection: new Socrata(null, {config: config}) }) } }