diff --git a/src/app/vis/menucomponents/new-graphmenu.js b/src/app/vis/menucomponents/new-graphmenu.js index 5d30accf..a504b201 100644 --- a/src/app/vis/menucomponents/new-graphmenu.js +++ b/src/app/vis/menucomponents/new-graphmenu.js @@ -32,6 +32,9 @@ angular.module('plotter.vis.menucomponents.new-graphmenu', case 2: return { 'type': 'heatmap', 'data': $scope.heatmap.selection }; + + case 3: + return { 'type': 'boxplot', 'data': $scope.boxplot.selection }; } } @@ -54,6 +57,10 @@ angular.module('plotter.vis.menucomponents.new-graphmenu', selection: [] }; + $scope.boxplot = { + selection: [] + }; + // controllers of the menu dialog: $scope.canSubmit = function() { var selection = getSelection(); @@ -63,7 +70,11 @@ angular.module('plotter.vis.menucomponents.new-graphmenu', else if($scope.tab.ind === 1) { return _.size(selection.data.x) > 0 && _.size(selection.data.y) > 0; - } else if($scope.tab.ind === 2) { + } + else if($scope.tab.ind === 2) { + return selection.data.length > 0; + } + else if($scope.tab.ind === 3) { return selection.data.length > 0; } }; @@ -100,6 +111,15 @@ angular.module('plotter.vis.menucomponents.new-graphmenu', error = true; } } + + else if(selection.type == 'boxplot') { + dimensionCount = DimensionService.getPrimary().availableDimensionsCount(); + if(dimensionCount < selection.data.length) { + NotifyService.addSticky('Too many selected variables', 'Please select a maximum of ' + dimensionCount + ' variables. You can free variables by first closing unnecessary figure windows on this tab.', + 'error', { referenceId: 'graphinfo' }); + error = true; + } + } return error; } @@ -134,6 +154,9 @@ angular.module('plotter.vis.menucomponents.new-graphmenu', }, { label: 'Heatmap' + }, + { + label: 'Boxplot' } ]; diff --git a/src/app/vis/menucomponents/new.graphmenu.tpl.html b/src/app/vis/menucomponents/new.graphmenu.tpl.html index e0f5ec79..bd3ef81d 100644 --- a/src/app/vis/menucomponents/new.graphmenu.tpl.html +++ b/src/app/vis/menucomponents/new.graphmenu.tpl.html @@ -3,7 +3,7 @@ Histogram

Histogram

-
+
Scatterplot @@ -23,6 +23,11 @@

Heatmap

+ + Box plot +

Box plot

+
+
diff --git a/src/app/vis/plotting/boxplot.js b/src/app/vis/plotting/boxplot.js new file mode 100644 index 00000000..ddf5b0d2 --- /dev/null +++ b/src/app/vis/plotting/boxplot.js @@ -0,0 +1,378 @@ +angular.module('plotter.vis.plotting.boxplot', + [ + 'ui.router', + 'services.dimensions', + 'services.dataset', + 'services.som', + 'services.window', + 'ext.d3', + 'ext.dc', + 'ext.lodash' + ]) + +.controller('BoxplotController', function BoxplotController($scope, $injector, $timeout, + DimensionService, DatasetFactory, FilterService, + GRID_WINDOW_PADDING, constants, + d3, dc, _) { + + $scope.dimensionService = $scope.window.handler().getDimensionService(); + + $scope.isSpecial = function() { + return $scope.window.extra().somSpecial; + }; + + function getTotalCount() { + return _.sum($scope.totalGroup.all(), function(d) { return d.value.valueOf(); }); + } + + function initDefault() { + $scope.dimensionInst = $scope.dimensionService.getDatasetDimension(); + $scope.dimension = $scope.dimensionInst.get(); + $scope.groupInst = $scope.dimensionInst.groupDefault(); + $scope.dimensionService.getReducedBoxplot($scope.groupInst, $scope.window.variables()); + $scope.reduced = $scope.groupInst.get(); + + $scope.colorScale = DatasetFactory.getColorScale(); + } + + if( $scope.isSpecial() ) { + } else { + initDefault(); + } + + $scope.window.resetFn(function() { + // pass + }); + + // share information with the plot window + $scope.window.headerText(['', $scope.window.variables().labelName(), '']); + + $scope.filterDefault = function(group) { + return { + 'all': function() { + var datasets = DatasetFactory.getSets(); + return group.all().filter(function(d) { + return datasets[d.key].active(); + }); + } + }; + + }; + + $scope.getHeight = function(ele) { + return ele.height() - GRID_WINDOW_PADDING; + }; + + $scope.getWidth = function(ele) { + return ele.width(); + }; + + $scope.drawSOMSpecial = function(config) { + var resizeSVG = function(chart) { + var ratio = config.size.aspectRatio === 'stretch' ? 'none' : 'xMinYMin'; + chart.select("svg") + .attr("viewBox", "0 0 " + [config.size.width, config.size.height].join(" ") ) + .attr("preserveAspectRatio", ratio) + .attr("width", "100%") + .attr("height", "100%"); + }; + + var plainchart = function() { + $scope.chart = dc.rowChart(config.element[0], config.chartGroup) + .margins({ + top: 0, + right: 20, + bottom: 40, + left: 20 + }) + .elasticX(true) + .label(function(d) { + var name = _.capitalize(d.key.name), + arr = [name, ", count: ", d.value.count], + label = d.value.type == 'total' ? undefined : " (circle " + d.value.circle.name() + ")"; + arr.push(label); + return arr.join(""); + }) + .title(function(d) { + var label = d.value.type == 'total' ? undefined : 'Circle: ' + d.value.circle.name(), + arr = [label, + 'Category: ' + d.key.name, + 'Count: ' + d.value.count]; + return arr.join("\n"); + }) + .renderTitleLabel(false) + .titleLabelOffsetX(5) + .width($scope.getWidth(config.element)) + .height($scope.getHeight(config.element)) + // .width(config.size.width) + // .height(config.size.height) + // .x(d3.scale.linear().domain(config.extent)) + .renderLabel(true) + .dimension(config.dimension) + .group(config.reduced) + //.group(config.filter(config.reduced)) + .valueAccessor(function(d) { + return d.value.count; + }) + .colors(config.colorScale.scale()) + .colorAccessor(function(d) { + var type = d.value.type; + if(type == 'circle') { + return d.value.circle.name(); + } else { + return '9'; + } + }) + // .on("postRender", resizeSVG) + // .on("postRedraw", resizeSVG) + .ordering(function(d) { + return d.value.type == 'total' ? 'total|' : d.value.circle.id() + "|" + d.key.name; + }); + + // disable filtering + $scope.chart.onClick = function() {}; + + }; + + plainchart(); + + }; + + + $scope.drawDefault = function(config) { + var resizeSVG = function(chart) { + var ratio = config.size.aspectRatio === 'stretch' ? 'none' : 'xMinYMin'; + chart.select("svg") + .attr("viewBox", "0 0 " + [config.size.width, config.size.height].join(" ") ) + .attr("preserveAspectRatio", ratio) + .attr("width", "100%") + .attr("height", "100%"); + }; + + function plainchart() { + $scope.chart = dc.boxPlot(config.element[0], config.chartGroup) + // .gap(10) + .margins({ + top: 25, + right: 10, + bottom: 20, + left: 40 + }) + .elasticX(true) + .elasticY(false) + .width($scope.getWidth(config.element)) + .height($scope.getHeight(config.element)) + .renderLabel(true) + .dimension(config.dimension) + .colorAccessor(function(d) { + return d.key.dataset; + }) + .group(config.filter(config.reduced)) + // .keyAccessor(function(d) { + // return d.key; + // }) + // .valueAccessor(function(d) { + // return d.value; + // }) + .colors(config.colorScale.scale()) + .colorAccessor(function(d) { + return config.colorScale.getAccessor(d.key); + }) + .tickFormat(constants.tickFormat); + // .on("postRender", resizeSVG) + // .on("postRedraw", resizeSVG) + + $scope.chart.filter = function() {}; + + $scope.chart.yAxis().tickFormat(constants.tickFormat); + + } + + plainchart(); + + }; + +}) + +.directive('plBoxplot', function plBoxplot(constants, $timeout, $rootScope, $injector, CLASSED_BARCHART_SIZE, GRID_WINDOW_PADDING, _) { + function postLink($scope, ele, attrs, ctrl) { + + function initDropdown() { + var selector = _.template('#<%= id %> <%= element %>'), + id = $scope.element.parent().attr('id'); + + $scope.window.addDropdown({ + type: "export:svg", + selector: selector({ id: id, element: 'svg' }), + scope: $scope, + source: 'svg', + window: $scope.window + }); + + $scope.window.addDropdown({ + type: "export:png", + selector: selector({ id: id, element: 'svg' }), + scope: $scope, + source: 'svg', + window: $scope.window + }); + } + + $scope.element = ele; + + var drawFunction = null, + config; + + if($scope.isSpecial()) { + drawFunction = $scope.drawSOMSpecial; + config = { + element: $scope.element, + // extent: $scope.extent, + filter: $scope.filterSOMSpecial, + colorScale: $scope.colorScale, + dimension: $scope.dimension, + reduced: $scope.reduced, + chartGroup: constants.groups.histogram.nonInteractive, + variable: $scope.window.variables().name() + }; + + } else { + config = { + element: $scope.element, + // extent: $scope.extent, + filter: $scope.filterDefault, + colorScale: $scope.colorScale, + dimension: $scope.dimension, + reduced: $scope.reduced, + chartGroup: constants.groups.histogram.nonInteractive, + variable: $scope.window.variables().name(), + callback: $scope.initExistingFilters + }; + drawFunction = $scope.drawDefault; + } + + $scope.element.ready(function() { + $timeout(function() { + drawFunction(config); + $scope.chart.render(); + if(config.callback) { + config.callback($scope.chart); + } + initDropdown(); + }); + }); + + $scope.deregisters = []; + + function setResize() { + function setSize() { + $scope.size = angular.copy($scope.window.size()); + } + + var resizeUnbind = $scope.$on('gridster-item-transition-end', function(item) { + function gridSizeSame() { + return _.isEqual($scope.size, $scope.window.size()); + } + if(!gridSizeSame()) { + renderWithNewDimensions(); + } + }); + + setSize(); + $scope.deregisters.push(resizeUnbind); + } + + function renderWithNewDimensions() { + function setSize() { + $scope.size = angular.copy($scope.window.size()); + } + var width = $scope.getWidth($scope.element), + height = $scope.getHeight($scope.element); + + $scope.chart.width(width); + $scope.chart.height(height); + $scope.chart.redraw(); + // $scope.chart.render(); + + setSize(); + } + + function setRerender() { + var reRenderUnbind = $rootScope.$on('window-handler.rerender', function(event, winHandler, config) { + if( winHandler == $scope.window.handler() ) { + + $timeout(function() { + if($scope.isSpecial()) { + // $scope.chart.group($scope.filterSOMSpecial($scope.reduced)); + } else { + // $scope.chart.group($scope.reduced); + $scope.chart.group($scope.filterDefault($scope.reduced)); + } + $scope.chart.redraw(); + }); + } + }); + $scope.deregisters.push(reRenderUnbind); + } + + function setResizeElement() { + var renderThr = _.debounce(function() { + renderWithNewDimensions(); + }, 150, { leading: false, trailing: true }); + + var resizeUnbind = $scope.$on('gridster-resized', function(sizes, gridster) { + var isVisible = _.contains($injector.get('WindowHandler').getVisible(), $scope.window.handler()); + if(!isVisible) { return; } + renderThr(); + }); + } + + function setRedraw() { + var redrawUnbind = $rootScope.$on('window-handler.redraw', function(event, winHandler) { + if( winHandler == $scope.window.handler() ) { + $timeout( function() { + $scope.chart.redraw(); + }); + } + }); + $scope.deregisters.push(redrawUnbind); + } + + setResize(); + setRerender(); + setRedraw(); + setResizeElement(); + + $scope.$on('$destroy', function() { + console.log("destroying boxplot for", $scope.window.variables().name()); + _.each($scope.deregisters, function(unbindFn) { + unbindFn(); + }); + + // remove chart + dc.deregisterChart($scope.chart, constants.groups.histogram.nonInteractive); + $scope.chart.resetSvg(); + + $scope.groupInst.decrement(); + if($scope.isSpecial()) { + $scope.totalDimensionInst.decrement(); + } + $scope.dimensionInst.decrement(); + }); + + ele.on('$destroy', function() { + $scope.$destroy(); + }); + + } + + return { + scope: false, + restrict: 'C', + controller: 'BoxplotController', + link: { + post: postLink + } + }; + +}); \ No newline at end of file diff --git a/src/app/vis/plotting/plotting.js b/src/app/vis/plotting/plotting.js index a89287a8..97abc709 100644 --- a/src/app/vis/plotting/plotting.js +++ b/src/app/vis/plotting/plotting.js @@ -8,6 +8,7 @@ angular.module('plotter.vis.plotting', 'plotter.vis.plotting.profile-histogram', 'plotter.vis.plotting.regression', 'plotter.vis.plotting.classedbarchart', + 'plotter.vis.plotting.boxplot', 'services.dimensions', 'services.dataset', 'services.variable', @@ -51,6 +52,10 @@ angular.module('plotter.vis.plotting', callFn = that.drawRegression; break; + case 'boxplot': + callFn = that.drawBoxplot; + break; + default: throw new Error('Undefined plot type'); } @@ -305,4 +310,48 @@ angular.module('plotter.vis.plotting', .extra({ source: config.source }); }; + this.drawBoxplot = function(config, windowHandler) { + var draw = function(config, windowHandler) { + var gridWindow = windowHandler.add(); + + gridWindow + .figure('pl-boxplot') + .variables(config.variable) + .extra({ somSpecial: config.somSpecial }); + }; + + var defer = $q.defer(); + + var promises = []; + var plottingDataPromise = DatasetFactory.getVariableData([config.variable], windowHandler); + promises.push(plottingDataPromise); + + if( config.somSpecial ) { + // need from primary as well + var primaryHandler = windowHandler.getService().getPrimary(); + var primaryPromise = DatasetFactory.getVariableData([config.variable], primaryHandler); + promises.push(primaryPromise); + } + + var dim = DimensionService.getPrimary(); + var samp = dim.getSampleDimension(); + + $q.all(promises).then(function successFn(res) { + // draw the figure + NotifyService.closeModal(); + draw(config, windowHandler); + defer.resolve(); + }, + function errorFn(variable) { + NotifyService.closeModal(); + + var title = 'Variable ' + variable + ' could not be loaded\n', + message = 'Please check the selected combination is valid for the selected datasets.', + level = 'error'; + NotifyService.addTransient(title, message, level); + defer.reject(); + }); + return defer.promise; + }; + }); \ No newline at end of file diff --git a/src/app/vis/plotting/plotting.less b/src/app/vis/plotting/plotting.less index ba8a864c..6158ad69 100644 --- a/src/app/vis/plotting/plotting.less +++ b/src/app/vis/plotting/plotting.less @@ -63,7 +63,7 @@ svg text.label { } -.pl-histogram, .pl-heatmap, .pl-somplane, .pl-scatterplot, .pl-profile-histogram, .pl-classed-bar-chart { +.pl-histogram, .pl-heatmap, .pl-somplane, .pl-scatterplot, .pl-profile-histogram, .pl-classed-bar-chart, .pl-boxplot { position: absolute; width: 100%; height: 100%; diff --git a/src/app/vis/vis.js b/src/app/vis/vis.js index bcdeb423..4bb6b654 100644 --- a/src/app/vis/vis.js +++ b/src/app/vis/vis.js @@ -265,6 +265,15 @@ angular.module('plotter.vis', [ }, winHandler); } + function postBoxplot(variables) { + _.each(variables, function(v) { + PlotService.drawBoxplot({ + variable: v, + somSpecial: false + }, winHandler); + }); + } + switch (config.type) { case 'histogram': postHistogram(config.selection); @@ -277,8 +286,11 @@ angular.module('plotter.vis', [ case 'heatmap': postHeatmap(config.selection); break; - } + case 'boxplot': + postBoxplot(config.selection); + break; + } }); }; diff --git a/src/common/services-dimensions.js b/src/common/services-dimensions.js index 48c31834..69e46073 100644 --- a/src/common/services-dimensions.js +++ b/src/common/services-dimensions.js @@ -140,8 +140,6 @@ angular.module('services.dimensions', [ return dimensions[key]; }; - - this.getVariableBMUDimension = function() { var creationFn = function(d) { return { @@ -730,6 +728,39 @@ angular.module('services.dimensions', [ return dimensionGroup; }; + this.getReducedBoxplot = function(dimensionGroup, variable) { + var reduceAdd = function(p, v) { + var variableVal = +v.variables[variable.name()]; + if (_.isNaN(variableVal) || variableVal == constants.nanValue) { + // pass + } else { + p.splice(d3.bisectLeft(p, variableVal), 0, variableVal); + } + return p; + }; + + var reduceRemove = function(p, v) { + var variableVal = +v.variables[variable.name()]; + if (_.isNaN(variableVal) || variableVal == constants.nanValue) { + // pass + } else { + p.splice(_.indexOf(p, variableVal, true), 1); + } + return p; + }; + + var reduceInitial = function() { + var p = []; + return p; + }; + dimensionGroup.reduce({ + add: reduceAdd, + remove: reduceRemove, + initial: reduceInitial + }); + return dimensionGroup; + }; + this.getReducedGroupHistoDistributions = function(dimensionGroup, variable) { var getKey = function(samp) { return [samp.originalDataset || samp.dataset, samp.sampleid].join("|");