Pie and donut chart directive

A reusable directive with customizable outer radius, inner radius,
label, and legend. Chart is updated on data change. Setting inner
radius to '0' will result in pie chart. Hovering over a slice will
show a tooltip.

This directive can be used to render quota charts in "Launch Instance"
workflow and related details panels.

Example Usage:
<pie-chart chart-data='instances'
           chart-settings='settings'></pie-chart>

Partially Implements: blueprint launch-instance-redesign
Change-Id: I24f1e94b68c87617177806cbfe4542876b0eec47
This commit is contained in:
Kelly Domico 2015-01-13 09:09:50 -08:00 committed by Travis Tripp
parent fec4ac6b1b
commit 23f45b7df1
14 changed files with 499 additions and 4 deletions

View File

@ -0,0 +1,9 @@
<div class="chart-tooltip"
ng-class="{ 'tooltip-enabled': tooltip.enabled }"
ng-style="tooltip.style">
<span class="fa {$ ::tooltip.icon $}"
ng-class="tooltip.iconClass"
ng-style="{ color: tooltip.iconColor }"></span>
<span class="tooltip-key">{$ tooltip.label $}</span>
{$ tooltip.value $}
</div>

View File

@ -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
* ```
* <chart-tooltip tooltip-data='tooltipData'></chart-tooltip>
* ```
*
*/
.directive('chartTooltip', [ 'basePath', function (path) {
return {
restrict: 'E',
scope: {
tooltip: '=tooltipData'
},
templateUrl: path + 'charts/chart-tooltip.html'
};
}]);
})();

View File

@ -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;
}
}

63
horizon/static/angular/charts/charts.js vendored Normal file
View File

@ -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;
});
};
});
})();

View File

@ -0,0 +1,30 @@
<div class="pie-chart">
<chart-tooltip tooltip-data="model.tooltipData"></chart-tooltip>
<div ng-if="::model.settings.showTitle && chartData.title"
class="pie-chart-title">
{$ ::chartData.title $} ({$ model.total $} Max)
</div>
<svg class="svg-pie-chart"
ng-attr-height="{$ ::model.settings.diameter $}"
ng-attr-width="{$ ::model.settings.diameter $}">
<g class="chart"
ng-attr-transform="translate({$ ::model.settings.outerRadius $},{$ ::model.settings.outerRadius $})">
<g class="slices"></g>
<g ng-if="::model.settings.showLabel && chartData.label" class="pie-chart-label">
<text dy="0.35em" ng-style="model.settings.label">{$ chartData.label $}</text>
</g>
</g>
</svg>
<div ng-if="::model.settings.showLegend" class="pie-chart-legend">
<div ng-repeat="slice in chartData.data | showKeyFilter"
class="slice-legend">
<div class="slice-key"
ng-class="slice.colorClass"
ng-style="{ 'background-color': '{$ slice.color $}' }"></div>
{$ slice.value $} {$ slice.label $}
</div>
</div>
</div>

View File

@ -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:
* <pie-chart chart-data='chartData'></pie-chart>
*
* Donut Chart:
* <pie-chart chart-data='chartData'
* chart-settings='chartSettings'></pie-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;
});
}
}
};
}]);
})();

View File

@ -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;
}
}
}
}

View File

@ -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 = "<pie-chart chart-data='testData' chart-settings='" + settings + "'></pie-chart>";
$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');
});
});

View File

@ -1,4 +1,6 @@
@import "help-panel/help-panel";
@import "wizard/wizard";
@import "table/table";
@import "transfer-table/transfer-table";
@import "transfer-table/transfer-table";
@import "charts/chart-tooltip";
@import "charts/pie-chart";

View File

@ -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/');

View File

@ -29,6 +29,8 @@
<script src='{{ STATIC_URL }}horizon/js/angular/services/hz.api.keystone.js'></script>
<script src='{{ STATIC_URL }}horizon/js/angular/services/hz.api.nova.js'></script>
<script src="{{ STATIC_URL }}horizon/lib/d3.js"></script>
<script src='{{ STATIC_URL }}angular/widget.module.js'></script>
<script src='{{ STATIC_URL }}angular/help-panel/help-panel.js'></script>
<script src='{{ STATIC_URL }}angular/wizard/wizard.js'></script>
@ -36,6 +38,9 @@
<script src='{{ STATIC_URL }}angular/modal/modal.js'></script>
<script src='{{ STATIC_URL }}angular/bind-scope/bind-scope.js'></script>
<script src='{{ STATIC_URL }}angular/transfer-table/transfer-table.js'></script>
<script src='{{ STATIC_URL }}angular/charts/charts.js'></script>
<script src='{{ STATIC_URL }}angular/charts/chart-tooltip.js'></script>
<script src='{{ STATIC_URL }}angular/charts/pie-chart.js'></script>
<script src='{{ STATIC_URL }}horizon/lib/jquery/jquery.quicksearch.js'></script>
<script src="{{ STATIC_URL }}horizon/lib/jquery/jquery.tablesorter.js"></script>
@ -44,8 +49,6 @@
<script src="{{ STATIC_URL }}horizon/lib/jquery-ui/ui/jquery-ui.js"></script>
<script src="{{ STATIC_URL }}horizon/lib/jquery/jquery.bootstrap.wizard.js"></script>
<script src="{{ STATIC_URL }}horizon/lib/d3.js"></script>
<script src="{{ STATIC_URL }}bootstrap/js/bootstrap.js"></script>
<script src='{{ STATIC_URL }}horizon/lib/bootstrap_datepicker/bootstrap-datepicker.js'></script>

View File

@ -15,6 +15,7 @@
<script src="{{ STATIC_URL }}horizon/lib/angular/angular-cookies.js"></script>
<script src="{{ STATIC_URL }}horizon/lib/angular/angular-bootstrap.js"></script>
<script src="{{ STATIC_URL }}horizon/lib/angular/smart-table.js"></script>
<script src="{{ STATIC_URL }}horizon/lib/d3.js"></script>
<script type="text/javascript">
/* Load angular modules extensions list before we include angular/horizon.js */

View File

@ -30,6 +30,9 @@ class ServicesTests(test.JasmineTests):
'angular/modal/modal.js',
'angular/bind-scope/bind-scope.js',
'angular/transfer-table/transfer-table.js',
'angular/charts/charts.js',
'angular/charts/chart-tooltip.js',
'angular/charts/pie-chart.js',
]
specs = [
'horizon/tests/jasmine/utilsSpec.js',
@ -41,9 +44,12 @@ class ServicesTests(test.JasmineTests):
'angular/modal/simple-modal.spec.js',
'angular/bind-scope/bind-scope.spec.js',
'angular/transfer-table/transfer-table.spec.js',
'angular/charts/pie-chart.spec.js',
]
externalTemplates = [
'angular/help-panel/help-panel.html',
'angular/wizard/wizard.html',
'angular/transfer-table/transfer-table.html',
'angular/charts/chart-tooltip.html',
'angular/charts/pie-chart.html',
]

View File

@ -132,6 +132,10 @@ $tooltip-border: solid 1px #bcbcbc !default;
$tooltip-border-radius: 0 !default;
$tooltip-box-shadow: 1px 1px 8px -3px #cccccc !default;
$tooltip-font-color: #000000 !default;
$tooltip-key-color: #000000 !default;
$tooltip-key-padding: 0 0.2em !default;
$tooltip-key-weight: 600 !default;
$tooltip-padding: 0.3em 0.8em !default;
/* Transfer Tables */
$badge-info-color: #0084d1 !default;
@ -139,3 +143,10 @@ $invalid-color: #f0ad4e !default;
$invalid-tooltip-color: #333333 !default;
$transfer-btn-width: 3em !default;
$transfer-section-bg: #fefefe !default;
/* Pie Chart */
$chart-title-font-size: 1.1em !default;
$chart-title-padding: 0.5em 0 !default;
$chart-title-weight: 600 !default;
$chart-legend-font-size: 1em !default;
$chart-legend-padding: 0.2em 1.5em !default;