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)
+
+
+
+
+
+
+
+ {$ 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 @@
+