diff --git a/horizon/static/angular/charts/chart-tooltip.html b/horizon/static/angular/charts/chart-tooltip.html new file mode 100644 index 0000000000..16166b2507 --- /dev/null +++ b/horizon/static/angular/charts/chart-tooltip.html @@ -0,0 +1,9 @@ +
+ + {$ tooltip.label $} + {$ tooltip.value $} +
\ No newline at end of file diff --git a/horizon/static/angular/charts/chart-tooltip.js b/horizon/static/angular/charts/chart-tooltip.js new file mode 100644 index 0000000000..94ef156cef --- /dev/null +++ b/horizon/static/angular/charts/chart-tooltip.js @@ -0,0 +1,47 @@ +(function() { + 'use strict'; + + angular.module('hz.widget.charts') + + /** + * @ngdoc directive + * @name hz.widget.charts.directive:chartTooltip + * @element + * @param {object} tooltip-data The tooltip data model and styles + * @description + * The `chartTooltip` directive renders a tooltip showing a colored + * icon, label, and value. + * + * Data Model and Styles: + * ``` + * var tooltipData = { + * enabled: true, + * label: 'Applied', + * value: 1, + * icon: 'fa-square', + * iconColor: '#333333', + * iconClass: 'warning', + * style: { left: '10px', top: '10px' } + * }; + * ``` + * + * @restrict E + * @scope tooltip: '=tooltipData' + * + * @example + * ``` + * + * ``` + * + */ + .directive('chartTooltip', [ 'basePath', function (path) { + return { + restrict: 'E', + scope: { + tooltip: '=tooltipData' + }, + templateUrl: path + 'charts/chart-tooltip.html' + }; + }]); + +})(); \ No newline at end of file diff --git a/horizon/static/angular/charts/chart-tooltip.scss b/horizon/static/angular/charts/chart-tooltip.scss new file mode 100644 index 0000000000..f3e024790d --- /dev/null +++ b/horizon/static/angular/charts/chart-tooltip.scss @@ -0,0 +1,25 @@ +.chart-tooltip { + background-color: $tooltip-bg-color; + border: $tooltip-border; + box-shadow: $tooltip-box-shadow; + display: none; + padding: $tooltip-padding; + position: absolute; + white-space: nowrap; + z-index: 12000; + + &.tooltip-enabled { + display: inline-block; + } + + .tooltip-key { + color: $tooltip-key-color; + font-weight: $tooltip-key-weight; + padding: $tooltip-key-padding; + } + + i.fa { + background-color: inherit; + fill: none; + } +} \ No newline at end of file diff --git a/horizon/static/angular/charts/charts.js b/horizon/static/angular/charts/charts.js new file mode 100644 index 0000000000..9fbaa95fa7 --- /dev/null +++ b/horizon/static/angular/charts/charts.js @@ -0,0 +1,63 @@ +(function() { + 'use strict'; + + /** + * @ngdoc overview + * @name hz.widget.charts + * @description + * + * # hz.widget.charts + * + * The `hz.widget.charts` module provides directives for simple charts + * used in Horizon, such as the pie and donut chart. Charts are + * implemented using D3. + * + * Requires {@link http://d3js.org `D3`} to be installed. + * + * | Constants | + * |-----------------------------------------------------------------| + * | {@link hz.widget.charts.constant:chartSettings `chartSettings`} | + * + * | Directives | + * |-----------------------------------------------------------------| + * | {@link hz.widget.charts.directive:pieChart `pieChart`} | + * + */ + angular.module('hz.widget.charts', []) + + /** + * @ngdoc parameters + * @name hz.widget.charts.constant:chartsettings + * @param {number} innerRadius Pie chart inner radius in pixels, default: 0 + * @param {number} outerRadius Pie chart outer radius in pixels, default: 35 + * @param {boolean} showTitle Show title, default: true + * @param {boolean} showLabel Show label, default: true + * @param {boolean} showLegend Show legend default: true + * @param {string} tooltipIcon Tooltip key icon, default: 'fa-square' + * + */ + .constant('chartSettings', { + innerRadius: 0, + outerRadius: 35, + showTitle: true, + showLabel: true, + showLegend: true, + tooltipIcon: 'fa-square' + }) + + /** + * @ngdoc filter + * @name hz.widget.charts.filter:showKeyFilter + * @function Filter based on 'hideKey' value of each slice + * @returns {function} A filtered list of keys to show in legend + * + */ + .filter('showKeyFilter', function() { + return function(items) { + return items.filter(function (item) { + return !item.hideKey; + }); + }; + }); + +})(); \ No newline at end of file diff --git a/horizon/static/angular/charts/pie-chart.html b/horizon/static/angular/charts/pie-chart.html new file mode 100644 index 0000000000..0c2818f135 --- /dev/null +++ b/horizon/static/angular/charts/pie-chart.html @@ -0,0 +1,30 @@ +
+ + +
+ {$ ::chartData.title $} ({$ model.total $} Max) +
+ + + + + + {$ chartData.label $} + + + + +
+
+
+ {$ slice.value $} {$ slice.label $} +
+
+
\ No newline at end of file diff --git a/horizon/static/angular/charts/pie-chart.js b/horizon/static/angular/charts/pie-chart.js new file mode 100644 index 0000000000..50957cddbc --- /dev/null +++ b/horizon/static/angular/charts/pie-chart.js @@ -0,0 +1,165 @@ +(function() { + 'use strict'; + + angular.module('hz.widget.charts') + + /** + * @ngdoc directive + * @name hz.widget.charts.directive:pieChart + * @element + * @param {object} chart-data The chart data model + * @param {string} chart-settings The custom chart settings (JSON), optional + * @description + * The `pieChart` directive renders a pie or donut chart using D3. The title + * and legend is shown by default. Each slice is represented by a label, value, + * and color (hex value or CSS class). See below for the data model. + * + * Data Model: + * ``` + * var chartData = { + * title: 'Total Instances', + * label: '25%', + * data: [ + * { label: 'Current', value: 1, color: '#1f83c6' }, + * { label: 'Added', value: 1, color: '#81c1e7' }, + * { label: 'Remaining', value: 6, colorClass: 'remaining', hideKey: true } + * ] + * }; + * + * title - the chart title + * label - the text to show in center of chart + * data - the data used to render chart + * + * var chartSettings = { + * innerRadius: 35, + * outerRadius: 50, + * showLabel: false + * }; + * ``` + * + * @restrict E + * @scope true + * + * @example + * ``` + * Pie Chart: + * + * + * Donut Chart: + * + * ``` + * + */ + .directive('pieChart', [ 'basePath', 'chartSettings', function (path, chartSettings) { + return { + restrict: 'E', + scope: { + chartData: '=', + chartSettings: '=' + }, + replace: true, + templateUrl: path + 'charts/pie-chart.html', + link: function (scope, element, attrs) { + var settings = angular.extend({}, chartSettings, scope.chartSettings); + settings.diameter = settings.outerRadius * 2; + + var model = { + settings: settings, + tooltipData: { + enabled: false, + icon: settings.tooltipIcon, + style: angular.extend({}, settings.tooltip) + } + }; + + var d3Elt = d3.select(element[0]); + + var arc = d3.svg.arc() + .outerRadius(settings.outerRadius) + .innerRadius(settings.innerRadius); + + var pie = d3.layout.pie() + .sort(null) + .value(function(d) { return d.value; }); + + var tooltip = d3Elt.select('chart-tooltip'); + + var unwatch = scope.$watch('chartData', updateChart); + scope.$on('$destroy', unwatch); + + scope.model = model; + + function updateChart() { + scope.model.total = d3.sum(scope.chartData.data, function(d) { return d.value; }); + scope.model.tooltipData.enabled = false; + + // Generate or update slices + var chart = d3Elt.select('.slices') + .selectAll('path.slice') + .data(pie(scope.chartData.data)); + + chart.enter().append('path') + .attr('class', 'slice') + .attr('d', arc); + + // Set the color or CSS class for the fill + chart.each(function(d) { + var slice = d3.select(this); + if (d.data.color) { + slice.style('fill', d.data.color); + } else if (d.data.colorClass) { + slice.classed(d.data.colorClass, true); + } + }); + + chart.on('mouseenter', function(d) { showTooltip(d, this); }) + .on('mouseleave', clearTooltip); + + // Animate the slice rendering + chart.transition() + .duration(500) + .attrTween('d', function animate(d) { + this.lastAngle = this.lastAngle || { startAngle: 0, endAngle: 0 }; + var interpolate = d3.interpolate(this.lastAngle, d); + this.lastAngle = interpolate(0); + + return function(t) { + return arc(interpolate(t)); + }; + }); + + chart.exit().remove(); + } + + function showTooltip(d, elt) { + scope.$apply(function() { + var eltHeight = element[0].getBoundingClientRect().height; + var titleHeight = element[0].querySelector('div.pie-chart-title') + .getBoundingClientRect() + .height; + + var point = d3.mouse(elt); + var x = point[0] + scope.model.settings.outerRadius; + var y = eltHeight - point[1] - scope.model.settings.outerRadius - titleHeight; + + scope.model.tooltipData.label = d.data.label; + scope.model.tooltipData.value = d.data.value; + scope.model.tooltipData.enabled = true; + scope.model.tooltipData.iconColor = d.data.color; + scope.model.tooltipData.iconClass = d.data.colorClass; + scope.model.tooltipData.style.left = x + 'px'; + scope.model.tooltipData.style.bottom = y + 'px'; + }); + } + + function clearTooltip() { + scope.$apply(function() { + scope.model.tooltipData.enabled = false; + }); + } + } + }; + }]); + +})(); \ No newline at end of file diff --git a/horizon/static/angular/charts/pie-chart.scss b/horizon/static/angular/charts/pie-chart.scss new file mode 100644 index 0000000000..f46b9d0755 --- /dev/null +++ b/horizon/static/angular/charts/pie-chart.scss @@ -0,0 +1,44 @@ +.pie-chart { + display: inline-block; + position: relative; + + .svg-pie-chart { + float: left; + + .slice { + cursor: pointer; + } + } + + .pie-chart-title { + font-size: $chart-title-font-size; + font-weight: $chart-title-weight; + padding: $chart-title-padding; + } + + .pie-chart-label { + font-size: 1.2em; + text-anchor: middle; + } + + .pie-chart-legend { + float: left; + font-size: $chart-legend-font-size; + line-height: 1em; + padding: $chart-legend-padding; + + .slice-legend { + padding: 0.1em 0; + + .slice-key { + color: transparent; + display: inline-block; + height: 1em; + line-height: 1em; + position: relative; + top: 0.12em; + width: 0.5em; + } + } + } +} \ No newline at end of file diff --git a/horizon/static/angular/charts/pie-chart.spec.js b/horizon/static/angular/charts/pie-chart.spec.js new file mode 100644 index 0000000000..839829e7fa --- /dev/null +++ b/horizon/static/angular/charts/pie-chart.spec.js @@ -0,0 +1,88 @@ +/* jshint globalstrict: true */ +'use strict'; + +describe('hz.widget.charts module', function () { + it('should be defined', function () { + expect(angular.module('hz.widget.charts')).toBeDefined(); + }); +}); + +describe('pie chart directive', function() { + + var $scope, $element; + + beforeEach(module('templates')); + beforeEach(module('hz')); + beforeEach(module('hz.widgets')); + beforeEach(module('hz.widget.charts')); + + beforeEach(inject(function($injector) { + var $compile = $injector.get('$compile'); + $scope = $injector.get('$rootScope').$new(); + + $scope.testData = { + title: 'Total Instances', + label: '25%', + data: [ + { label: 'Current', value: 1, color: '#1f83c6' }, + { label: 'Added', value: 1, color: '#81c1e7' }, + { label: 'Remaining', value: 6, color: '#d1d3d4', hideKey: true } + ] + }; + + var settings = '{ "innerRadius": 25 }'; + var markup = ""; + $element = angular.element(markup); + $compile($element)($scope); + + $scope.$digest(); + })); + + it('should be compiled', function() { + expect($element.html().trim()).not.toBe(''); + }); + + it('should have svg element', function() { + expect($element.find('svg')).toBeDefined(); + }); + + it('should have 3 path elements', function() { + expect($element.find('path.slice').length).toBe(3); + }); + + it('should have correct colors for slices', function() { + var slices = $element.find('path.slice'); + + var slice1Color = slices[0].style.fill; + + if (slice1Color.indexOf('rgb') === 0) { + expect(slices[0].style.fill).toBe('rgb(31, 131, 198)'); + expect(slices[1].style.fill).toBe('rgb(129, 193, 231)'); + expect(slices[2].style.fill).toBe('rgb(209, 211, 212)'); + } else { + expect(slices[0].style.fill).toBe('#1f83c6'); + expect(slices[1].style.fill).toBe('#81c1e7'); + expect(slices[2].style.fill).toBe('#d1d3d4'); + } + }); + + it('should have a correct title "Total Instances (8 Max)"', function() { + var title = $element.find('.pie-chart-title').text().trim(); + expect(title).toBe('Total Instances (8 Max)'); + }); + + it('should have a legend', function() { + expect($element.find('.pie-chart-legend')).toBeDefined(); + }); + + it ('should have correct legend keys and labels', function() { + var legendKeys = $element.find('.pie-chart-legend .slice-legend'); + + var firstKeyLabel = legendKeys[0]; + var secondKeyLabel = legendKeys[1]; + + expect(firstKeyLabel.textContent.trim()).toBe('1 Current'); + expect(secondKeyLabel.textContent.trim()).toBe('1 Added'); + }); + +}); \ No newline at end of file diff --git a/horizon/static/angular/styles.scss b/horizon/static/angular/styles.scss index 90c31bbb8c..dca0715151 100644 --- a/horizon/static/angular/styles.scss +++ b/horizon/static/angular/styles.scss @@ -1,4 +1,6 @@ @import "help-panel/help-panel"; @import "wizard/wizard"; @import "table/table"; -@import "transfer-table/transfer-table"; \ No newline at end of file +@import "transfer-table/transfer-table"; +@import "charts/chart-tooltip"; +@import "charts/pie-chart"; \ No newline at end of file diff --git a/horizon/static/angular/widget.module.js b/horizon/static/angular/widget.module.js index 09f31283cb..b333649133 100644 --- a/horizon/static/angular/widget.module.js +++ b/horizon/static/angular/widget.module.js @@ -7,7 +7,8 @@ 'hz.widget.table', 'hz.widget.modal', 'hz.framework.bind-scope', - 'hz.widget.transfer-table' + 'hz.widget.transfer-table', + 'hz.widget.charts' ]) .constant('basePath', '/static/angular/'); diff --git a/horizon/templates/horizon/_scripts.html b/horizon/templates/horizon/_scripts.html index b50d7ce1e0..583f74dd86 100644 --- a/horizon/templates/horizon/_scripts.html +++ b/horizon/templates/horizon/_scripts.html @@ -29,6 +29,8 @@ + + @@ -36,6 +38,9 @@ + + + @@ -44,8 +49,6 @@ - - diff --git a/horizon/templates/horizon/jasmine/jasmine.html b/horizon/templates/horizon/jasmine/jasmine.html index 709b0fbf99..d2ac2f3cc4 100644 --- a/horizon/templates/horizon/jasmine/jasmine.html +++ b/horizon/templates/horizon/jasmine/jasmine.html @@ -15,6 +15,7 @@ +