PageRenderTime 88ms CodeModel.GetById 9ms RepoModel.GetById 1ms app.codeStats 0ms

/src/js/cilantro/ui/controls/infograph.js

https://github.com/cbmi/cilantro
JavaScript | 481 lines | 337 code | 87 blank | 57 comment | 38 complexity | bdf32ab47ffa4dfd580b626f59e574a6 MD5 | raw file
  1. /* global define */
  2. define ([
  3. 'jquery',
  4. 'underscore',
  5. 'backbone',
  6. 'marionette',
  7. './base'
  8. ], function($, _, Backbone, Marionette, base) {
  9. // Returns a function closure that can be used to sort by attribute
  10. // values for a collection of models.
  11. var sortModelAttr = function(attr) {
  12. return function(model) {
  13. var value = model.get(attr);
  14. if (_.isString(value)) {
  15. value = value.toLowerCase();
  16. }
  17. return value;
  18. };
  19. };
  20. // Model with minimal parsing for unpacking the source value contained
  21. // with an array.
  22. var BarModel = Backbone.Model.extend({ });
  23. // Collection of models representing the distribution data. Includes
  24. // a method for sorting models by an attriute. If the attribute is
  25. // prefixed with a hyphen '-', the sort will be reversed (descending).
  26. // This triggers the 'sort' event unless the 'silent' option is true.
  27. var BarCollection = Backbone.Collection.extend({
  28. model: BarModel,
  29. comparator: function(model) {
  30. return -model.get('count');
  31. },
  32. sortModelsBy: function(attr) {
  33. var reverse = attr.chartAt(0);
  34. if (reverse === '-') {
  35. attr = attr.slice(1);
  36. }
  37. this.models = this.sortBy(sortModelAttr(attr));
  38. if (reverse === '-') {
  39. this.models.reverse();
  40. }
  41. this.trigger('sort', this);
  42. }
  43. });
  44. // View rendering the data in BarModel including stats relative to the
  45. // 'total' option such as the percentile of its 'count'. Bars have
  46. // 'selected' and 'visibility' properties, both of which can be toggled.
  47. var Bar = Marionette.ItemView.extend({
  48. className: 'info-bar',
  49. template: 'controls/infograph/bar',
  50. options: {
  51. total: null
  52. },
  53. ui: {
  54. bar: '.bar',
  55. barLabel: '.bar-label'
  56. },
  57. events: {
  58. 'click': 'toggleSelected'
  59. },
  60. modelEvents: {
  61. 'change:selected': 'setSelected',
  62. 'change:visible': 'setVisible'
  63. },
  64. initialize: function() {
  65. _.bindAll(this, 'onExcludedChange');
  66. this.listenTo(this.model.collection, 'change:excluded',
  67. this.onExcludedChange);
  68. },
  69. serializeData: function() {
  70. var attrs = this.model.toJSON();
  71. var percentage = this.getPercentage();
  72. attrs.width = percentage;
  73. // Simplify percentages that are less than one to be represented as
  74. // such rather than a small floating point.
  75. if (percentage < 1) {
  76. attrs.percentage = '<1';
  77. }
  78. else {
  79. attrs.percentage = parseInt(percentage);
  80. }
  81. return attrs;
  82. },
  83. onRender: function() {
  84. this.setSelected(this.model, !!this.model.get('selected'));
  85. if (this.ui.barLabel.html() === '') {
  86. this.ui.barLabel.html('(empty)');
  87. }
  88. if (this.ui.barLabel.html() === 'null') {
  89. this.ui.barLabel.html('(null)');
  90. }
  91. },
  92. // Returns the percentage of the value's count relative to the 'total'.
  93. getPercentage: function() {
  94. return (this.model.get('count') / this.options.total) * 100;
  95. },
  96. // Toggle the selected state of the bar.
  97. toggleSelected: function() {
  98. this.model.set('selected', !this.model.get('selected'));
  99. },
  100. onExcludedChange: function() {
  101. this.$el.toggleClass('excluded', this.model.get('excluded'));
  102. },
  103. // Sets the selected state of the bar. If the bar is filtered,
  104. // deselecting it will hide the bar from view.
  105. setSelected: function(model, value) {
  106. this.$el.toggleClass('selected', value);
  107. if (!value && model.get('visible') === false) {
  108. this.$el.removeClass('filtered').hide();
  109. }
  110. },
  111. // Sets the visibility of the bar based on its current state.
  112. setVisible: function(model, value) {
  113. if (value) {
  114. this.$el.removeClass('filtered').show();
  115. }
  116. else if (model.get('selected')) {
  117. this.$el.addClass('filtered');
  118. }
  119. else {
  120. this.$el.hide();
  121. }
  122. }
  123. });
  124. // Renders a series of bars for each value. This contains the value
  125. // count and percentage for the value.
  126. var Bars = base.ControlCollectionView.extend({
  127. className: 'info-bar-chart',
  128. itemView: Bar,
  129. itemViewOptions: function(model) {
  130. return {
  131. model: model,
  132. total: this.calcTotal()
  133. };
  134. },
  135. collectionEvents: {
  136. 'change': 'change',
  137. 'sort': 'sortChildren'
  138. },
  139. initialize: function() {
  140. this.wait();
  141. var _this = this;
  142. // Fetch the field distribution, do not cache.
  143. this.model.distribution(function(dist) {
  144. _this.collection.reset(dist);
  145. _this.ready();
  146. });
  147. },
  148. // Sums the total count across all values.
  149. calcTotal: function() {
  150. var total = 0,
  151. counts = this.collection.pluck('count');
  152. for (var i = 0; i < counts.length; i++) {
  153. total += counts[i];
  154. }
  155. return total;
  156. },
  157. sortChildren: function() {
  158. // Iterate over the newly sorted models and re-appends the child
  159. // view relative to the new indicies.
  160. var _this = this;
  161. this.collection.each(function(model) {
  162. var view = _this.collection.findByModel(model);
  163. _this.$el.append(view.el);
  164. });
  165. },
  166. getField: function() {
  167. return this.model.id;
  168. },
  169. getOperator: function() {
  170. // Since all selected bars are either included or excluded, the
  171. // presence of a single excluded bar in those selected means that
  172. // we should be using the exclusive operator. Otherwise, return
  173. // the inclusive operator.
  174. if (this.collection.where({excluded: true}).length > 0) {
  175. return '-in';
  176. }
  177. return 'in';
  178. },
  179. getValue: function() {
  180. return _.map(this.collection.where({selected: true}), function(model) {
  181. return model.get('value');
  182. });
  183. },
  184. setValue: function(values) {
  185. if (!values) values = [];
  186. // Toggle the selection based on the presence values.
  187. this.collection.each(function(model) {
  188. var value = model.get('value');
  189. model.set('selected', values.indexOf(value) >= 0);
  190. });
  191. },
  192. setOperator: function(operator) {
  193. if (operator === '-in') {
  194. this.collection.each(function(model) {
  195. model.set('excluded', true);
  196. });
  197. $('input[name=exclude]').attr('checked', true);
  198. }
  199. }
  200. });
  201. // The toolbar makes it easier to interact with large lists of values. It
  202. // supports filtering values by text. Also, a button is provided to invert the
  203. // selection of values. When combined with filtering, values are selected if
  204. // they are not filtered by the search. The values themselves are sortable by
  205. // the label or the count.
  206. var BarChartToolbar = Marionette.ItemView.extend({
  207. className: 'navbar navbar-toolbar',
  208. template: 'controls/infograph/toolbar',
  209. events: {
  210. // Note, that no delay is used since it is working with the local
  211. // list of values so the filtering can keep up.
  212. 'keyup [name=filter]': 'filterBars',
  213. 'click [name=invert]': 'invertSelection',
  214. 'click .sort-value-header, .sort-count-header': 'sortBy',
  215. 'change [name=exclude]': 'excludeCheckboxChanged'
  216. },
  217. ui: {
  218. toolbar: '.btn-toolbar',
  219. filterInput: '[name=filter]',
  220. invertButton: '[name=invert]',
  221. sortValueHeader: '.sort-value-header',
  222. sortCountHeader: '.sort-count-header',
  223. excludeCheckbox: '[name=exclude]'
  224. },
  225. initialize: function() {
  226. this.sortDirection = '-count';
  227. },
  228. sortBy: function(event) {
  229. if (event.currentTarget.className === 'sort-value-header') {
  230. if (this.sortDirection === '-value') {
  231. this.sortDirection = 'value';
  232. }
  233. else {
  234. this.sortDirection = '-value';
  235. }
  236. }
  237. else {
  238. if (this.sortDirection === '-count') {
  239. this.sortDirection = 'count';
  240. }
  241. else {
  242. this.sortDirection = '-count';
  243. }
  244. }
  245. switch (this.sortDirection) {
  246. case '-count':
  247. this.ui.sortValueHeader.html('Value <i class=icon-sort></i>');
  248. this.ui.sortCountHeader.html('Count <i class=icon-sort-down></i>');
  249. break;
  250. case 'count':
  251. this.ui.sortValueHeader.html('Value <i class=icon-sort></i>');
  252. this.ui.sortCountHeader.html('Count <i class=icon-sort-up></i>');
  253. break;
  254. case '-value':
  255. this.ui.sortValueHeader.html('Value <i class=icon-sort-down></i>');
  256. this.ui.sortCountHeader.html('Count <i class=icon-sort></i>');
  257. break;
  258. case 'value':
  259. this.ui.sortValueHeader.html('Value <i class=icon-sort-up></i>');
  260. this.ui.sortCountHeader.html('Count <i class=icon-sort></i>');
  261. break;
  262. }
  263. this.collection.sortModelsBy(this.sortDirection);
  264. },
  265. toggle: function(show) {
  266. this.ui.filterInput.toggle(show);
  267. this.ui.invertButton.toggle(show);
  268. this.ui.sortValueHeader.toggle(show);
  269. this.ui.sortCountHeader.toggle(show);
  270. },
  271. // Filters the bars given a text string or via an event from the input.
  272. filterBars: function(event) {
  273. var text;
  274. if (_.isString(event)) {
  275. text = event;
  276. }
  277. else {
  278. if (event !== null) {
  279. event.stopPropagation();
  280. }
  281. text = this.ui.filterInput.val();
  282. }
  283. var regex = new RegExp(text, 'i');
  284. this.collection.each(function(model) {
  285. model.set('visible', !text || regex.test(model.get('value')));
  286. });
  287. },
  288. // Inverts the selected bars. If the bar is not visible and not
  289. // selected it will not be inverted.
  290. invertSelection: function() {
  291. this.collection.each(function(model) {
  292. if (model.get('visible') !== false || model.get('selected')) {
  293. model.set('selected', !model.get('selected'));
  294. }
  295. });
  296. this.collection.trigger('change');
  297. },
  298. excludeCheckboxChanged: function() {
  299. var exclude = this.ui.excludeCheckbox.prop('checked');
  300. // If we don't set this to silent, then the change event for each
  301. // model will also be called on the collection. This will result in
  302. // an unnecessary number of change handler calls for this control.
  303. this.collection.each(function(model) {
  304. model.set('excluded', exclude, {silent: true});
  305. });
  306. // Be polite and broadcast an event to alert the bars that the
  307. // inclusion/exclusion status has changed and that they should now
  308. // update accordingly. This is necessary since we silenced the event
  309. // that would normally occur when we explicity set the excluded
  310. // attribute above.
  311. this.collection.trigger('change:excluded');
  312. }
  313. });
  314. // Infograph-style control which renders a list of horizontal bars filled based
  315. // on their percentage of the total population. Bars can be clicked to be selected
  316. // for inclusion. For small sets of values, the 'minValuesForToolbar' option
  317. // can be set (to an integer) to hide the toolbar.
  318. var InfographControl = base.ControlLayout.extend({
  319. template: 'controls/infograph/layout',
  320. events: {
  321. change: 'change'
  322. },
  323. options: {
  324. minValuesForToolbar: 10
  325. },
  326. regions: {
  327. bars: '.bars-region',
  328. toolbar: '.toolbar-region'
  329. },
  330. ui: {
  331. loading: '[data-target=loading-indicator]'
  332. },
  333. collectionEvents: {
  334. 'reset': 'toggleToolbar'
  335. },
  336. initialize: function() {
  337. _.bindAll(this, 'toggleToolbar');
  338. },
  339. // Internally defined collection for wrapping the available values as
  340. // well as maintaining state for which values are selected.
  341. constructor: function(options) {
  342. if (!options.collection) options.collection = new BarCollection();
  343. base.ControlLayout.prototype.constructor.apply(this, arguments);
  344. this.barsControl = new Bars({
  345. model: this.model,
  346. collection: this.collection
  347. });
  348. var _this = this;
  349. // Proxy all control-based operations to the bars.
  350. var methods = ['set', 'get', 'when', 'ready', 'wait'];
  351. var proxyFunc = function(method) {
  352. _this[method] = function() {
  353. var thisControl = _this.barsControl;
  354. return thisControl[method].apply(thisControl, arguments);
  355. };
  356. return _this[method];
  357. };
  358. _.each(methods, function(method) {
  359. proxyFunc(method);
  360. });
  361. // Proxy events
  362. this.listenTo(this.barsControl, 'all', function() {
  363. var event = arguments[0];
  364. if (event === 'change' || event === 'beforeready' || event === 'ready') {
  365. this.trigger.apply(this, arguments);
  366. }
  367. if (event === 'ready') {
  368. this.ui.loading.hide();
  369. }
  370. });
  371. },
  372. toggleToolbar: function() {
  373. // Not yet rendered, this will be called again in onRender.
  374. if (!this.toolbar.currentView) return;
  375. this.toolbar.currentView.toggle(
  376. this.collection.length >= this.options.minValuesForToolbar);
  377. },
  378. onRender: function() {
  379. this.bars.show(this.barsControl);
  380. this.toolbar.show(new BarChartToolbar({
  381. collection: this.collection
  382. }));
  383. this.toggleToolbar();
  384. },
  385. validate: function(attrs) {
  386. if (_.isUndefined(attrs.value) || attrs.value.length === 0) {
  387. return 'Select at least one value';
  388. }
  389. }
  390. });
  391. return {
  392. InfographControl: InfographControl
  393. };
  394. });