Sunday, October 8, 2017

An AngularJS Dashboard, Part 3: KPI Tile, Responsive Layout, and Back End

NOTE: for best results, view the http: version of this page (else you won't get syntax highlighting).

This is Part 3 in a series on creating a dashboard in AngularJS. I'm blogging as I progressively create the dashboard, refining my Angular experience along the way.

Previously in Part 2, we developed our first four tiles. We implemented the chart tiles using a service, and demonstrated that we could switch out the service to use different chart providers.

Here in Part 3, we are going to add a new tile for KPIs; start work on a responsive tile rendering algorithm; and create a back-end data service for our dashboard. Our objectives are:

  • Add a new tile type, a KPI tile for visualizing a Key Performance Indicator
  • Render tiles more intelligently, taking available screen size into account
  • Begin a back-end service for the dashboard that loads tile layout from a database
Here's what we'll be ending up with today:

KPI Tile

Our first work item for today will be adding a new tile type. A Key Performance Indicator (KPI) is a measurable value that serves as a key metric in evaluting the state of an organization or its activities (such as revenue, expenses, projects, employee performance, customer satisfaction, productivity, fundraising, etc.).

Visually, our KPI tile will show a bar segmented into three equal sections, colored red, yellow and green. The KPI will plot a value on this bar. The KPI will be a horizontal bar, except in the case of the 1x2 size tile which will be vertical.

Here's what a tile definition for a KPI tile looks like.
{
    title: 'Monthly Revenue',
    type: 'kpi',
    width: 1, height: 1,
    color: 'purple',
    dataSource: 'inline',
    columns: ['$0', '$10K', '$20K', '$30K'],
    label: '$15,000',
    value: 60
},
Key properties in the tile definition are these:
  • type: 'kpi'
  • label: a display version of the value. For example, $15,000 or "60 / 100".
  • value: the value to plot on the bar, as a percentage (0 to 100).
  • columns: an array of four label strings. columns[0] will appear at the start of the red area; [1] at the start of the yellow area; [2] at the start of the green area; and [3] at the end of the green area.

Template Changes

Recall that in our template, inside the ng-repeat loop, is markup code for each tile type, made conditional through the use of ng-if. We'll be adding markup for a kpi tile. There are several variations of this markup based on tile dimensions, so the ng-if checks not only for a tile type of 'kpi' but also specific tile dimensions.
<!-- KPI tile -->
<div ng-if="tile.type=='kpi' && tile.height==1"
        style="text-align: left; position: relative; left: 0; right: 0; margin: 0 auto; overflow-x: hidden; overflow-y: hidden;  height: 200px; padding: 16px">
    <div style="display: block; margin-top: 35px; font-size: 10px;">
        <div style="display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[0]}}</div><div style="display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[1]}}</div><div style="display: inline-block; text-align: left; width: auto; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[2]}}</div><div style="display inline; text-align: right; float: right; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[3]}}</div>
    </div>
    <div style="display: inline-block; width: 100%; height: 15%;  background-color: rgb(45,151,34); border: 1px solid white">
        <div style="background-color: rgb(210,54,34); display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0"></div><div style="background-color: rgb(252,209,22); display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0"></div>
    </div>
    <div style="font-size: 24px; text-align: center">{{tile.label}}</div>
    <div style="position: absolute; top: 75px; left: 17px; right: 17px; bottom: 0"><div style="background-color: black; height: 5%; width: {{tile.value}}%; float: left"></div></div>
</div>
<div ng-if="tile.type=='kpi' && tile.height==2 && tile.width==2"
        style="text-align: left; position: relative; left: 0; right: 0; margin: 0 auto; overflow-x: hidden; overflow-y: hidden;  height: 200px; padding: 16px">
    <div style="display: block; margin-top: 135px; font-size: 10px;">
        <div style="display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[0]}}</div><div style="display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[1]}}</div><div style="display: inline-block; text-align: left; width: auto; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[2]}}</div><div style="display inline; text-align: right; float: right; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[3]}}</div>
    </div>
    <div style="display: inline-block; width: 100%; height: 15%;  background-color: rgb(45,151,34); border: 1px solid white">
        <div style="background-color: rgb(210,54,34); display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0"></div><div style="background-color: rgb(252,209,22); display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0"></div>
    </div>
    <div style="font-size: 24px; text-align: center">{{tile.label}}</div>
    <div style="position: absolute; top: 177px; left: 17px; right: 17px; bottom: 0"><div style="background-color: black; height: 10%; width: {{tile.value}}%; float: left"></div></div>
</div>
<!-- KPI 1x2 (vertical) -->
<div ng-if="tile.type=='kpi' && tile.height==2 && tile.width==1"
        style="text-align: left; position: relative; left: 0; right: 0; margin: 0 auto; overflow-x: hidden; overflow-y: hidden;  height: 416px; padding: 16px">
    <div style="display: block; margin-left: 40px; width: 50%; height: 80%; background-color: rgb(210,54,34); border: 1px solid white">
        <div style="background-color: rgb(45,151,34); display: block; width: 100%; height: 33%; border: none; padding: 0; margin: 0"></div><div style="background-color: rgb(252,209,22); display: block; width: 100%; height: 33%; border: none; padding: 0; margin: 0"></div>
    </div><div style="font-size: 24px; text-align: center">{{tile.label}}</div>
    <div style="position: absolute; margin-top: 17px; margin-bottom: 98px; top:0; left: 90px; right: 17px;  height:333px; width: 20px"><div style="position: absolute; bottom: 0; height: {{tile.value}}%; background-color: black; width: 20px"></div></div>
    <div style="position: absolute; margin-top: 17px; top: 0; left: 10px; font-size: 12px">{{tile.columns[3]}}</div>
    <div style="position: absolute; margin-top: 17px; top: 100px; left: 10px; font-size: 12px">{{tile.columns[2]}}</div>
    <div style="position: absolute; margin-top: 17px; top: 215px; left: 10px; font-size: 12px">{{tile.columns[1]}}</div>
    <div style="position: absolute; margin-top: 17px; top: 318px; left: 10px; font-size: 12px">{{tile.columns[0]}}</div>
</div>

Seeing the KPI Tile in Action

When we run the dashboard, here's what the new KPI tile looks like.

1x1 KPI Tile

Our tile is showing the KPI with the tile properties we defined, in a clear visual presentation. Below are a few other examples of the KPI tile in other sizes.

2x1 KPI Tile

The 1x2 KPI shows a vertical bar rather than a horizontal bar.

1x2 KPI Tile

Now that we've added a new tile type, we're going to update our tile definitions. Below is our new list of tile definitions and how it displays.
self.tiles = [
            {
                title: 'Site Traffic',
                type: 'counter',
                width: 1, height: 1,
                color: 'red',
                dataSource: 'inline',
                value: '1365',
                label: 'Site Visits'
            },
            {
                title: 'Orders',
                type: 'counter',
                width: 1, height: 1,
                color: 'yellow',
                dataSource: 'inline',
                value: '100',
                label: 'Orders'

            },
            {
                title: 'Customer Satisfaction',
                type: 'kpi',
                width: 2, height: 1,
                color: 'gray',
                dataSource: 'inline',
                columns: ['Poor', 'Adequate', '', 'Excellent'],
                label: '90 / 100',
                value: 90
            },
            {
                title: 'Monthly Revenue',
                type: 'kpi',
                width: 2, height: 1,
                color: 'gray',
                dataSource: 'inline',
                columns: ['$0', '$10K', '$20K', '$30K'],
                label: '$15,000',
                value: 50
            },
            {
                title: 'Time Management',
                type: 'donut',
                width: 2, height: 1,
                color: 'green',
                dataSource: 'inline',
                columns: ['Work', 'Eat', 'Commute', 'Watch TV', 'Sleep', "Exercise"],
                value: [11, 2, 2, 2, 7, 4]
            },
            {
                title: 'Employees',
                type: 'table',
                width: 1, height: 2,
                color: 'orange',
                dataSource: 'inline',
                columns: [['Name', 'string'], ['Salary', 'number'], ['FTE', 'boolean']],
                value: [
                            ['Mike', 10000, true],
                            ['Jim', 8000, false],
                            ['Alice', 12500, true],
                            ['Bob', 7000, true],
                            ['Cheryl', 7000, true],
                            ['Don', 7000, true],
                            ['Edith', 7000, true],
                            ['Frank', 7000, true],
                            ['Gary', 7000, true],
                            ['Hazel', 7000, true],
                            ['Ira', 7000, true],
                            ['Jacob', 7000, true],
                            ['Loretta', 7000, true],
                            ['Mary', 7000, true],
                            ['Neil', 7000, true],
                            ['Obadiah', 7000, true],
                            ['Peter', 7000, true],
                            ['Quincy', 7000, true]
                ]
            },
            {
                title: 'Charity Fundraising',
                type: 'kpi',
                width: 1, height: 2,
                color: 'purple',
                dataSource: 'inline',
                columns: ['$0', '$5K', '$10K', '$15K'],
                label: '$7,500',
                value: 50
            },
            {
                title: 'Precious Metals',
                type: 'column',
                width: 2, height: 2,
                color: 'blue',
                dataSource: 'inline',
                columns: ['Copper', 'Silver', 'Gold', 'Platinum'],
                value: [8.94, 10.49, 19.30, 21.45]
            }
        ];
Revised Tile Definitions Including KPI Tiles

Here's what our revised layout now looks like when run:

Revised Dashboard with KPI Tiles

Making Our Tile Layout Responsive

Previously, our algorithm for displaying tiles was pretty simple. Each tile was 200 pixels wide plus a 16px right margin, and if we kept displaying them one after the other, they'd eventually wrap to the next line--but would still look nice and aligned. Except that wasn't the whole story. We are supporting tiles that can be 1- or 2-unit wide and 1- or 2-unit high. That can leave odd gaps in the display, depending on the variation and order of tiles combined with the size of a user's screen area. 

To improve on this, we're going to move away from the original method of rendering and instead use an HTML table (HTML tables are much-maligned these days by designers, but they still have their place and this is a good use of a table). We'll be able to take advantage of the colspan and rowspan attributes in our table for positioning our extra-wide and extra-high tiles. 

Our goal for today is to do the following:
  • Be aware of windows resizing, and dynamically compute the number of tile units across we can fit.
  • Render our tiles in an HTML table
This will be an improvement, but it doesn't yet get us "smart fill", where tiles are moved to plug any space gaps. We will address that in a future post.

Adding a New Directive, window-size

You may recall that in Part 2 we had a need to execute some JavaScript after the HTML template was rendered by Angular in order to draw our charts. We added an Angular directive named "after-render". We have a similar need now to compute the number of tiles we can fit across whenever our window is resized, and then to re-render the HTML template. To do this, we will add a new directive named window-size. And, we'll be removing the after-render directive because it is now superfluous now that we have window-resize.

Below is our app.module.js, with after-render removed and window-size added. Window-size records the change in screen height and width, and computes tile-units-across, both initially and whenever the browser window is resized. It also calls a new function in the dashboard controller named computeLayout. We'll study this and other controller changes in just a minute.
var dashboardApp = null;
var dashboardApp = null;

(function () {
    'use strict';

    // Define the 'dashboardApp' module
    dashboardApp = angular.module('dashboardApp', [
      'dashboard'
    ]);

    angular.module('dashboardApp').directive('windowSize', function ($window) {
        return function (scope, element) {
            var w = angular.element($window);
            scope.getWindowDimensions = function () {
                var tilesAcross = 1;
                var width = w.width()-100;
                if (width > 0) {
                    tilesAcross = Math.floor(width / 216);
                }
                return {
                    'h': w.height(),
                    'w': w.width(),
                    'tilesAcross': tilesAcross,
                    'tablecolumns': tilesAcross,
                    'tablerows': 3
                };
            };
            scope.$watch(scope.getWindowDimensions, function (newValue, oldValue) {
                scope.windowHeight = newValue.h;
                scope.windowWidth = newValue.w;
                scope.tilesAcross = newValue.tilesAcross;
                scope.tablecolumns = newValue.tilesAcross;
                scope.tablerows = 3;
                scope.style = function () {
                    return {
                        'height': (newValue.h - 100) + 'px',
                        'width': (newValue.w - 100) + 'px',
                        'tilesAcross': newValue.tilesAcross,
                        'tablecolumns': tilesAcross,
                        'tablerows': 3
                    };
                };
                var ctrl = angular.element('#dashboard').scope().$$childHead.$ctrl;
                ctrl.computeLayout();
                deferCreateCharts();
            }, true);

            w.bind('resize', function () {
                scope.$apply();
            });
        }
    })

    // Define the dashboard module
    angular.module('dashboard', [
      'dashboard'
    ]);

})();
app.module.js with after-render directive removed and window-size added


Controller Changes

Our controller, dashboard.component.js, needs a number of changes to support our smarter layout. Up until now, our template has used ng-repeat to loop through the controller's Tiles array. Now that we've added smarter layout, we need a version of the tile layout that has been generated for the user's current screen dimensions. We're going to make these changes to our controller:
  • Add a new property named layoutView. We will be changing the template and other code to use layoutView as the reference for tile layout instead of controller.tiles. Controller.tiles remains the master layout, but we'll derive an updated layoutView each time there has been a change in window size.
  • Add a new function named computeLayout. This function is called by window-size each time the window size has changed. it will re-compute the layoutView, and Angular will re-render the HTML template with the revised layout.
Here are the additions to the controller:
// Register 'dashboard' component, along with its associated controller and template

angular.module('dashboard').component('dashboard', {
    templateUrl: '/components/dashboard/dashboard.template.html',
    controller: [
        '$scope', '$window', '$http', 'ChartService', function DashboardController($scope, $window, $http, ChartService) {
            var self = this;
            var ctrl = angular.element('#dashboard').scope().$$childHead.$ctrl;

            self.chartProvider = ChartService.chartProvider();

            self.tablecolumns = function () {
                return $scope.tablecolumns;
            }

            self.tablerows = function () {
                return $scope.tablerows;
            }

            self.title = 'Sample Dashboard';
            self.tiles = [
                {
                    title: 'Site Traffic',
                    type: 'counter',
                    width: 1, height: 1,
                    color: 'red',
                    value: '1365',
                    label: 'Site Visits'
                },
                ...
            ];

            self.layoutView = [];           // set by self.computeLayout

            ...

            // Compute layout based on current screen dimensions (self.tablecolumns()) and generate updated tile layout
            // Inputs: self.tablecolumns() ........... number of tiles across.
            // Ouptuts: self.layoutView .............. updated array of tile rows, each element an array of tiles for a row [[ tile1, ... tileN ], ... [ tileX, ... tileZ ] ]

            self.computeLayout = function () {
                var numcols = self.tablecolumns();
                if (numcols < 1) numcols = 1;
                var newlayout = [];

                var index = 0;
                var row = [];

                var tileid = 1;

                var tile = null;
                for (var t = 0; t < self.tiles.length; t++) {
                    tile = self.tiles[t];
                    if (index + tile.width > numcols) { // no more room in row. commit row and start new one.
                        newlayout.push(row);
                        row = [];
                        index = 0;
                    }
                    tile.id = tileid.toString();
                    tileid = tileid + 1;
                    row.push(tile);
                    index = index + tile.width;
                }
                if (row.length > 0) {   // commit final row if not empty.
                    newlayout.push(row);
                }

                $scope.tablerows = newlayout.length;

                self.layoutView = newlayout;
            };

        }]
});
Additions to dashboard.component.js controller

Let's talk about computeLayout(). It iterates through the master tile layout, self.tiles, constructing an array of row items, where each row item is an array of tiles. If a tile won't fit in the current row, the row is added to the array and a new row is started. Thus, we end up with a layout that will fit on the user's current screen width. When complete, this array of arrays becomes the new value for self.layoutView and the template is re-rendered. On a 1920-pixel wide browser window, layoutView has this value:
[
    [ // row 1
        {
            title: 'Site Traffic',
            type: 'counter',
            width: 1, height: 1,
            color: 'red',
            value: '1365',
            label: 'Site Visits'
        },
        {
            title: 'Orders',
            type: 'counter',
            width: 1, height: 1,
            color: 'yellow',
            value: '100',
            label: 'Orders'

        },
        {
            title: 'Customer Satisfaction',
            type: 'kpi',
            width: 2, height: 1,
            color: 'gray',
            dataSource: 'inline',
            columns: ['Poor', 'Adequate', '', 'Excellent'],
            label: '90 / 100',
            value: 90
        },
        {
            title: 'Monthly Revenue',
            type: 'kpi',
            width: 2, height: 1,
            color: 'gray',
            dataSource: 'inline',
            columns: ['$0', '$10K', '$20K', '$30K'],
            label: '$15,000',
            value: 50
        }
    ],
    [ // row 2
        {
            title: 'Time Management',
            type: 'donut',
            width: 2, height: 1,
            color: 'purple',
            columns: ['Work', 'Eat', 'Commute', 'Watch TV', 'Sleep', "Exercise"],
            value: [11, 2, 2, 2, 7, 4]
        },
        {
            title: 'Employees',
            type: 'table',
            width: 1, height: 2,
            color: 'orange',
            columns: [['Name', 'string'], ['Salary', 'number'], ['FTE', 'boolean']],
            value: [
                        ['Mike', 10000, true],
                        ['Jim', 8000, false],
                        ['Alice', 12500, true],
                        ['Bob', 7000, true],
                        ['Cheryl', 7000, true],
                        ['Don', 7000, true],
                        ['Edith', 7000, true],
                        ['Frank', 7000, true],
                        ['Gary', 7000, true],
                        ['Hazel', 7000, true],
                        ['Ira', 7000, true],
                        ['Jacob', 7000, true],
                        ['Loretta', 7000, true],
                        ['Mary', 7000, true],
                        ['Neil', 7000, true],
                        ['Obadiah', 7000, true],
                        ['Peter', 7000, true],
                        ['Quincy', 7000, true]
            ]
        },
        {
            title: 'Charity Fundraising',
            type: 'kpi',
            width: 1, height: 2,
            color: 'purple',
            dataSource: 'inline',
            columns: ['$0', '$5K', '$10K', '$15K'],
            label: '$7,500',
            value: 50
        },
        {
            title: 'Precious Metals',
            width: 2, height: 2,
            color: 'blue',
            type: 'column',
            columns: ['Copper', 'Silver', 'Gold', 'Platinum'],
            value: [8.94, 10.49, 19.30, 21.45]
        }
    ]
]
layoutView array

Template Changes

Our template needs a number of changes:
  • Reference the new window-size directive (line 1), and remove after-render
  • Replacement of prior outer markup and ng-angular with new markup to generate a table (lines 6-8). 
  • Use of ng-repeat in tr and td elements that reference controller's layoutView, which replaces the previous div with ng-repeat that looped through controller's tiles array
The  inner markup that renders each tile (lines 9-72) doesn't need any changes, except that we're referencing tile.id instead of $index to get tile ids.
<div class="dashboard-controller" window-size>
    <div>
        <div>{{$ctrl.title}} (chart provider: {{$ctrl.chartProvider}})</div>
        <span after-render="deferCreateCharts"></span>
        <div class="dashboard-panel">
            <table>
                <tr ng-repeat="row in $ctrl.layoutView track by $index">
                    <td ng-repeat="tile in row" colspan="{{tile.width}}">
                        <div id="tile-{{tile.id}}"
                             class="tile" ng-class="tile.classes"
                             style="overflow: hidden"
                             draggable="true" ondragstart="tile_dragstart(event);"
                             ondrop="tile_drop(event);" ondragover="tile_dragover(event);">
                            <div class="hovermenu"><i class="fa fa-ellipsis-h" aria-hidden="true"></i></div>
                            <div style="overflow: hidden; white-space: nowrap">{{tile.title}}</div>
                            <div style="position: relative; height: 100%">
                                <!-- COUNTER tile -->
                                <div ng-if="tile.type=='counter' && tile.height==1"
                                     style="text-align: center; position: absolute; left: 0; right: 0; margin: 0 auto; top: 25px">
                                    <div style="font-size: 72px">{{tile.value}}</div>
                                    <div style="font-size: 24px">{{tile.label}}</div>
                                </div>
                                <div ng-if="tile.type=='counter' && tile.height==2"
                                     style="text-align: center; position: absolute; left: 0; right: 0; margin: 0 auto; top: 135px">
                                    <div style="font-size: 72px">{{tile.value}}</div>
                                    <div style="font-size: 24px">{{tile.label}}</div>
                                </div>
                                <!-- KPI tile -->
                                <div ng-if="tile.type=='kpi' && tile.height==1"
                                     style="text-align: left; position: relative; left: 0; right: 0; margin: 0 auto; overflow-x: hidden; overflow-y: hidden;  height: 200px; padding: 16px">
                                    <div style="display: block; margin-top: 35px; font-size: 10px;">
                                        <div style="display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[0]}}</div><div style="display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[1]}}</div><div style="display: inline-block; text-align: left; width: auto; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[2]}}</div><div style="display inline; text-align: right; float: right; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[3]}}</div>
                                    </div>
                                    <div style="display: inline-block; width: 100%; height: 15%;  background-color: rgb(45,151,34); border: 1px solid white">
                                        <div style="background-color: rgb(210,54,34); display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0"></div><div style="background-color: rgb(252,209,22); display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0"></div>
                                    </div>
                                    <div style="font-size: 24px; text-align: center">{{tile.label}}</div>
                                    <div style="position: absolute; top: 75px; left: 17px; right: 17px; bottom: 0"><div style="background-color: black; height: 5%; width: {{tile.value}}%; float: left"></div></div>
                                </div>
                                <div ng-if="tile.type=='kpi' && tile.height==2 && tile.width==2"
                                     style="text-align: left; position: relative; left: 0; right: 0; margin: 0 auto; overflow-x: hidden; overflow-y: hidden;  height: 200px; padding: 16px">
                                    <div style="display: block; margin-top: 135px; font-size: 10px;">
                                        <div style="display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[0]}}</div><div style="display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[1]}}</div><div style="display: inline-block; text-align: left; width: auto; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[2]}}</div><div style="display inline; text-align: right; float: right; height: 100%; border: none; padding: 0; margin: 0">{{tile.columns[3]}}</div>
                                    </div>
                                    <div style="display: inline-block; width: 100%; height: 15%;  background-color: rgb(45,151,34); border: 1px solid white">
                                        <div style="background-color: rgb(210,54,34); display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0"></div><div style="background-color: rgb(252,209,22); display: inline-block; width: 33%; height: 100%; border: none; padding: 0; margin: 0"></div>
                                    </div>
                                    <div style="font-size: 24px; text-align: center">{{tile.label}}</div>
                                    <div style="position: absolute; top: 177px; left: 17px; right: 17px; bottom: 0"><div style="background-color: black; height: 10%; width: {{tile.value}}%; float: left"></div></div>
                                </div>
                                <!-- KPI 1x2 (vertical) -->
                                <div ng-if="tile.type=='kpi' && tile.height==2 && tile.width==1"
                                     style="text-align: left; position: relative; left: 0; right: 0; margin: 0 auto; overflow-x: hidden; overflow-y: hidden;  height: 416px; padding: 16px">
                                    <div style="display: block; margin-left: 40px; width: 50%; height: 80%; background-color: rgb(210,54,34); border: 1px solid white">
                                        <div style="background-color: rgb(45,151,34); display: block; width: 100%; height: 33%; border: none; padding: 0; margin: 0"></div><div style="background-color: rgb(252,209,22); display: block; width: 100%; height: 33%; border: none; padding: 0; margin: 0"></div>
                                    </div><div style="font-size: 24px; text-align: center">{{tile.label}}</div>
                                    <div style="position: absolute; margin-top: 17px; margin-bottom: 98px; top:0; left: 90px; right: 17px;  height:333px; width: 20px"><div style="position: absolute; bottom: 0; height: {{tile.value}}%; background-color: black; width: 20px"></div></div>
                                    <div style="position: absolute; margin-top: 17px; top: 0; left: 10px; font-size: 12px">{{tile.columns[3]}}</div>
                                    <div style="position: absolute; margin-top: 17px; top: 100px; left: 10px; font-size: 12px">{{tile.columns[2]}}</div>
                                    <div style="position: absolute; margin-top: 17px; top: 215px; left: 10px; font-size: 12px">{{tile.columns[1]}}</div>
                                    <div style="position: absolute; margin-top: 17px; top: 318px; left: 10px; font-size: 12px">{{tile.columns[0]}}</div>
                                </div>
                                <div ng-if="tile.type=='table'"
                                     style="text-align: left !important; padding: 16px; height: 100%">
                                    <div style="height: 100%; text-align: left !important">
                                        <table style="padding-bottom: 52px;">
                                            <tr>
                                                <th ng-repeat="col in tile.columns">{{col[0]}}</th>
                                            </tr>
                                            <tr ng-repeat="row in tile.value">
                                                <td ng-repeat="cell in row">
                                                    <div ng-if="tile.columns[$index][1]=='number'" class="td-right">{{cell}}</div>
                                                    <div ng-if="tile.columns[$index][1]!='number'">{{cell}}</div>
                                                </td>
                                            </tr>
                                        </table>
                                    </div>
                                </div>
                                <!-- TABLE tile -->
                                <div ng-if="tile.type=='table'"
                                     style="text-align: left !important; padding: 16px; height: 100%">
                                    <div style="height: 100%; text-align: left !important">
                                        <table style="padding-bottom: 52px;">
                                            <tr>
                                                <th ng-repeat="col in tile.columns">{{col[0]}}</th>
                                            </tr>
                                            <tr ng-repeat="row in tile.value">
                                                <td ng-repeat="cell in row">
                                                    <div ng-if="tile.columns[$index][1]=='number'" class="td-right">{{cell}}</div>
                                                    <div ng-if="tile.columns[$index][1]!='number'">{{cell}}</div>
                                                </td>
                                            </tr>
                                        </table>
                                    </div>
                                </div>
                                <!-- Google DONUT tile -->
                                <div ng-if="$ctrl.chartProvider=='Google' && tile.type=='donut'"
                                     style="text-align: center; padding: 16px; height: 100%">
                                    <div class="donutchart" id="tile-donutchart-{{tile.id}}" style="height: 100%"></div>
                                </div>
                                <!-- Google COLUMN tile -->
                                <div ng-if="$ctrl.chartProvider=='Google' && tile.type=='column'"
                                     style="text-align: center; padding: 16px; height: 100%">
                                    <div class="columnchart" id="tile-columnchart-{{tile.id}}" style="height: 100%"></div>
                                </div>
                                <!-- Chart.js DONUT tile -->
                                <div ng-if="$ctrl.chartProvider=='ChartJS' && tile.type=='donut'"
                                     style="text-align: center; padding: 16px; height: 100%">
                                    <canvas class="chartjs-donutchart" id="tile-canvas-{{tile.id}}" style="margin: 0 auto; width: 90%; max-width: 90%; height: auto; max-height: 80%"></canvas>
                                </div>
                                <!-- Chart.js COLUMN tile -->
                                <div ng-if="$ctrl.chartProvider=='ChartJS' && tile.type=='column'"
                                     style="text-align: center; padding: 16px; height: 100%">
                                    <canvas class="chartjs-columnchart" id="tile-canvas-{{tile.id}}" style="margin: 0 auto; width: 90%; max-width: 90%; height: auto; max-height: 80%" ng-if="tile.width==1 && tile.height==1"></canvas>
                                    <canvas class="chartjs-columnchart" id="tile-canvas-{{tile.id1}}" style="margin: 0 auto; width: 90%; max-width: 90%; height: auto; max-height: 75%" ng-if="tile.width==2 && tile.height==1"></canvas>
                                    <canvas class="chartjs-columnchart" id="tile-canvas-{{tile.id}}" style="margin: 0 auto; width: 90%; max-width: 90%; height: auto; max-height: 85%" ng-if="tile.height==2"></canvas>
                                </div>
                                <!-- PLACEHOLDER tile -->
                                <div ng-if="tile.type=='placeholder'"
                                     style="position: absolute; top: 0">
                                </div>
                            </div>
                        </div> <!-- end tile -->
                    </td>
                </tr>
            </table>
        </div>
    </div>

    <script lang="javascript">
        // Handler for start of tile drag.
        function tile_dragstart(ev) {
            ev.dataTransfer.dropEffect = "move";
            var sourceId = getParentTileId(ev.target);
            if (sourceId != null) {
                ev.dataTransfer.setData("text/plain", sourceId);
            }
        }

        // Handler for tile dragover.
        function tile_dragover(ev) {
            ev.preventDefault();
            ev.dataTransfer.dropEffect = "move";
        }

        var controller = null;

        // Handler for tile drop.
        function tile_drop(ev, tiles) {
            ev.preventDefault();
            var destId = getParentTileId(ev.target);
            var sourceTileIndex = parseInt(ev.dataTransfer.getData("text").substring(5)) - 1;
            var destTileIndex = parseInt(destId.substring(5)) - 1;
            var ctrl = angular.element('#dashboard').scope().$$childHead.$ctrl;
            controller = ctrl;
            ctrl.dragTile(sourceTileIndex, destTileIndex);
        }

        // Given a drag element (which may or may not have an Id, and which may or may not be in a tile), return the tile Id that contains it. Or null, if not in a tile.
        function getParentTileId(element) {
            var ctrl = angular.element('#dashboard').scope().$$childHead.$ctrl;
            for (var t = 1; t <= ctrl.tiles.length; t++) {
                if (document.getElementById('tile-' + t.toString()).contains(element)) {
                    return 'tile-' + t.toString();
                } // end if
            } // next t
            return null;
        }
    </script>
</div>

Seeing The New Layout in Action

Now we're ready to try things out. For starters, we'll run the project in a full-screen browser window on my 1920-wide Dell laptop.

The tiles are rendering now in a table, and the dashboard has concluded it can fit 6 tile units across.

Dashboard on 1920-wide laptop

Now, we'll reduce our window size, to two-thirds of the width. The dashboard re-renders, with a revised layout to fit the new screen width. Now, there is only room for four tiles across.


Let's reduce it even more, to the width of a smartphone.


This last example shows why we will need to go further and do "smart fill." At a future time, we'll be adding the smarts to move tiles higher up in the layout to fill gaps. Today, though, this is as far as we'l go on revising the layout and rendering.

Creating a Back End

Up until now, we've been cheating a bit. Our tile definitions and their data are just inline in the dashboard controller. This isn't very "real world". In the real world, the layout for a dashboard would vary from one customer to another. We'd want users to be able to modify their layout and have it remembered. And, we'd expect the data for these tiles to come from a real data source, such as an application database.

To create a back end, we'll be adding another service, the DataService. We saw in Part 2 that injecting a chart service was a very useful pattern, because it allowed us to have multiple implementations of the service. We are going to do exactly the same thing with the DataService, with two implementations in mind:

  • The first will be a "demo" data service, with the same inline tile definitions and data that have been in our poject all along, but now separated into a service. 
  • The second version of the data service will access ASP.NET MVC controller actions, which in turn will draw data from a SQL Server database.

Controller Updates for New Data Service

First off, we want to tell Angular the our dashboard controller now requires a data service.
angular.module('dashboard').component('dashboard', {
    templateUrl: '/components/dashboard/dashboard.template.html',
    controller: [
        '$scope', '$window', '$http', 'ChartService', 'DataService', function DashboardController($scope, $window, $http, ChartService, DataService) {
            var self = this;
            var ctrl = angular.element('#dashboard').scope().$$childHead.$ctrl;

            self.chartProvider = ChartService.chartProvider();
            self.dataProvider = DataService.dataProvider();
Injecting Data Service in dashboard.component.js

Here's a diagram illustrating the actors in our project now--highlighting the newly-added data service.


Data Service injected into Controller

As with the chart controller, the first function we'll add to the data service is a simple one returning the name of the data provider in case we want to display it.

Previousy, the tile definitions were inline in the dashoard controller. We're replacing that code with a call to the data service to get the tile layout:
            self.title = 'Sample Dashboard';
            self.tiles = DataService.getTileLayout();
            self.layoutView = [];           // set by self.computeLayout
Retrieving tile layout from data service

Demo DataService

Now we'll create the demo data service. We're adding a new code module in our project's dashboard folder named demo.data.service.js. In it we have two functions:
  • dataProvider : this function returns the name of the data service ("demo")
  • getTileLayout : this function returns the tiles array that the controller needs.
'use strict';
'use strict';

// DemoDataService : DataService, contains functions for access tile layouts and data for tiles (demo version, all data is inline).
//
// dataProvider ............. returns the name of the data provider ("demo")
// getTileLayout ............ returns the master tile layout for the dashboard

dashboardApp.service('DataService', function ($http) {

    this.dataProvider = function () {
        return 'Demo';
    }

    // -------------------- getTileLayout : Returns the master layout for tiles (returns tiles array) ------------------

    this.getTileLayout = function (dashboardName) {
        var tiles = [
                    {
                        title: 'Site Traffic',
                        type: 'counter',
                        width: 1, height: 1,
                        color: 'red',
                        dataSource: 'inline',
                        value: '1365',
                        label: 'Site Visits'
                    },
                    {
                        title: 'Orders',
                        type: 'counter',
                        width: 1, height: 1,
                        color: 'yellow',
                        dataSource: 'inline',
                        value: '100',
                        label: 'Orders'

                    },
                    {
                        title: 'Customer Satisfaction',
                        type: 'kpi',
                        width: 2, height: 1,
                        color: 'gray',
                        dataSource: 'inline',
                        columns: ['Poor', 'Adequate', '', 'Excellent'],
                        label: '90 / 100',
                        value: 90
                    },
                    {
                        title: 'Monthly Revenue',
                        type: 'kpi',
                        width: 2, height: 1,
                        color: 'gray',
                        dataSource: 'inline',
                        columns: ['$0', '$10K', '$20K', '$30K'],
                        label: '$15,000',
                        value: 50
                    },
                    {
                        title: 'Time Management',
                        type: 'donut',
                        width: 2, height: 1,
                        color: 'green',
                        dataSource: 'inline',
                        columns: ['Work', 'Eat', 'Commute', 'Watch TV', 'Sleep', "Exercise"],
                        value: [11, 2, 2, 2, 7, 4]
                    },
                    {
                        title: 'Employees',
                        type: 'table',
                        width: 1, height: 2,
                        color: 'orange',
                        dataSource: 'inline',
                        columns: [['Name', 'string'], ['Salary', 'number'], ['FTE', 'boolean']],
                        value: [
                                  ['Mike', 10000, true],
                                  ['Jim', 8000, false],
                                  ['Alice', 12500, true],
                                  ['Bob', 7000, true],
                                  ['Cheryl', 7000, true],
                                  ['Don', 7000, true],
                                  ['Edith', 7000, true],
                                  ['Frank', 7000, true],
                                  ['Gary', 7000, true],
                                  ['Hazel', 7000, true],
                                  ['Ira', 7000, true],
                                  ['Jacob', 7000, true],
                                  ['Loretta', 7000, true],
                                  ['Mary', 7000, true],
                                  ['Neil', 7000, true],
                                  ['Obadiah', 7000, true],
                                  ['Peter', 7000, true],
                                  ['Quincy', 7000, true]
                        ]
                    },
                    {
                        title: 'Charity Fundraising',
                        type: 'kpi',
                        width: 1, height: 2,
                        color: 'purple',
                        dataSource: 'inline',
                        columns: ['$0', '$5K', '$10K', '$15K'],
                        label: '$7,500',
                        value: 50
                    },
                    {
                        title: 'Precious Metals',
                        type: 'column',
                        width: 2, height: 2,
                        color: 'blue',
                        dataSource: 'inline',
                        columns: ['Copper', 'Silver', 'Gold', 'Platinum'],
                        value: [8.94, 10.49, 19.30, 21.45]
                    }
                ];
        return tiles;
    }

});
demo.data.service.js

getTileLayout() returns the same tiles array we've always had, but with one difference. We've added a dataSource property to each tile. For the demo data service, this is just set to 'inline', signifying that the tile already have their data. Later on when we create the alternate edition of the data service that talks to a database, the dataSource property will identify the name of the query that will be used to pull tile data.

In index.html, our web page, we now must include our data service.
        <!-- Google Visualization chart service -->
        <script src="/components/dashboard/google.chart.service.js"></script>
        <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>

        <!-- Chart.js chart service -->
        <!--<script src="/components/dashboard/chartjs.chart.service.js"></script>
        <script src="/Scripts/Chart.bundle.js" type="text/javascript"></script>-->

        <!-- Demo data service -->
        <script src="/components/dashboard/demo.data.service.js"></script>

        <script src="/components/dashboard/dashboard.component.js"></script>

    </body>
</html>

Trying Out the Demo Data Service

Now we can try things out. We clear our cache and launch the project, and soon we see a familiar sight:
Dashboard now using demo data service

Our dashboard doesn't look any different, but we've made some important internal changes. The tile layout and tile data now comes from a data service.

A Real Back-End

The data service we just created, DemoDataService, still has tile definitions and tile data inline in the JavaScript. Now that we have the shape of the service defined, it's time to create a real data service that talks to a web server back end and retrieves data from a database.

We need to give our app a database, so we'll create one. If you download the sample project, you'll find that it's configured to use the demo data service (demo.data.service.js) and no database is required. However, the readme file explains how to run a script (dashboard_database.sql) to create a SQL Server database and how to change the project to use the SQL Server data service (sql.data.service.js).

Creating the Dashboard Database

Our database is mimicking an application database. We will ultimately depend on our database for (1) dashboard configuration (definitions of tiles and dashboard layout,) and (2) application data to query to set values for each tile. Today, we are going to tackle the first category, dashboard configuration. We will address the second category, querying application data, in our next post. This means we'll be hitting the database to find out which tiles to show, but we'll still have hard-coded data values for right now.

To track database configuration, we'll create these tables:

  • A DashboardLayout table to represent the current dashboard tile layout. This will include not only a default layout, but saved layouts for individual users. If an entry can't be found for the user's username, we'll select the record named 'default'.
  • A DashboardLayoutTile table to link the DashboardLayout record to individual tiles and an order of layout. If a dashboard had 5 tiles, it would have 5 DashboardLayoutTile records.
  • A DashboardLayoutTileProperty table to hold each property name and value for a tile.
The relationship between these tables is as follows.
  • The back end will locate a user's dashboard record in the DashboardLayout table (if a customized record is not found for a user, then 'default' is used to get the default layout).
  • The sequence of tiles is found by reading records from the subordinate DashboardLayoutTile table. This gives us the order of tiles.
  • The tile detail (properties) are loaded from DashboardLayoutTileProperty for each tile.

Relataionship of Dashboard Configuration Tables

Here's how our tables look loaded with some configuration data. We've defined five tiles in our default dashboard (one of each type).

Sample Dashboard Configuration Data

With all of this in place, we can now implement our SQL Server data service.

SQL Server Data Service

As with the Demo Data Service, the SQL Server Data Service has the same functions (dataProvider and getTileLayout). To get the tile layout, the service needs to talk to the back end MVC action /Dashboard/Index which will query the dashboard database tables and return a Json tiles array.
'use strict';

// SqlDataService : DataService, contains functions for access tile layouts and data for tiles (SQL Server version, all data comes from database).
//
// dataProvider ............. returns the name of the data provider ("demo")
// getTileLayout ............ returns the master tile layout for the dashboard

var dbg = null;

dashboardApp.service('DataService', function ($http) {

    this.dataProvider = function () {
        return 'SQL Server';
    }

    // -------------------- getDashboard :  getTileLayout : Returns the master layout for tiles (returns tiles array) ------------------

    this.getTileLayout = function () {   // TODO: add dashboard-name as param

        var request = $http({
            method: "GET",
            url: "/Dashboard/",
        });

        return (request.then(handleSuccess, handleError));
    }

    function handleSuccess(response) {

        var tile = null;
        var tiles = [];

        var name = '';
        var value = '';

        // create tile object with properties

        for (var t = 0; t < response.data.Tiles.length; t++) {
            tile = {};
            for (var p = 0; p < response.data.Tiles[t].Properties.length; p++) {
                name = response.data.Tiles[t].Properties[p].PropertyName;
                value = response.data.Tiles[t].Properties[p].PropertyValue;
                switch (name) {
                    case 'columns': // column values beginning with [ are evaluated into arrays.
                        tile[name] = eval(value);
                        break
                    case 'width':       // treat these property value as integers
                    case 'height':
                        tile[name] = parseInt(value);
                        break;
                    case 'value':
                        if (value.substring(0, 1) == '[') {
                            tile[name] = eval(value);
                        }
                        else {
                            tile[name] = value;
                        }
                        break;
                    default:    // treat all other property values as strings
                        tile[name] = value;
                        break;
                }
            }
            tiles.push(tile);   // add tile to tiles array
        }

        return tiles;
    }

    function handleError(response) {
        console.log('sql.data.service.js : handleError 01');
        alert('error');
    }

});

sql.server.data.service

Note that we do some post-processing after getting the Json tiles array back from the MVC action. If you recall out previous inline tiles structure, proeprties like columns and value sometimes contain arrays. Since we're just storing the value property for a tile in the database as a string, values like '[ 1, 2, 3, 4, 5 ]' need to converted from a string to a JavaScript array. We do this with the JavaScript eval function (lines 45 and 53).


Revised Dashboard Controller

Although we've taken pains to implement identical functions in our second data service, it does have some behavioral differences that are going to force us to revise our Angular dashboard controller code. The DataService's getTileLayout function makes an Ajax call to the back end, and then returns the Json tile layout with a JavaScript promise. This means the controller's self.tiles = DataService.getTileLayout(); statement won't work correctly. Instead, we need to assign the function result in a promise (lines 136-161 below). And, since the rest of the initialization code in the controller isn't ready to execute until we have the tiles array set, we'll move all the rest of that code into the promise as well.
//'use strict'; // removed because of complaints about dragTile function - in strict mode code, functions can only be declared at top level or inside a block
// Register 'dashboard' component, along with its associated controller and template

angular.module('dashboard').component('dashboard', {
    templateUrl: '/components/dashboard/dashboard.template.html',
    //templateUrl: '/Scripts/HRMS/Dash/dashboard/dashboard.template.html',
    controller: [
        '$scope', '$window', '$http', 'ChartService', 'DataService', function DashboardController($scope, $window, $http, ChartService, DataService) {
            var self = this;
            var ctrl = angular.element('#dashboard').scope().$$childHead.$ctrl;

            self.chartProvider = ChartService.chartProvider();
            self.dataProvider = DataService.dataProvider();

            self.tablecolumns = function () {
                return $scope.tablecolumns;
            }

            self.tablerows = function () {
                return $scope.tablerows;
            }

            self.title = 'Sample Dashboard';

            // Complete a drag-and-drop operation.

            self.dragTile = function (sourceTileIndex, destTileIndex) {

                try {
                    var sourceTile = self.tiles[sourceTileIndex];

                    self.tiles.splice(sourceTileIndex, 1); // remove source tile from tile list

                    if (sourceTileIndex < destTileIndex) {
                        destTileIndex--;
                    }

                    self.tiles.splice(destTileIndex, 0, sourceTile); // insert dest tile

                    // Regenerate tile ids.
                    var tile;
                    if (self.tiles != null) {
                        for (var t = 0; t < self.tiles.length; t++) {
                            tile = self.tiles[t];
                            tile.id = 'tile-' + (t + 1).toString(); // tile id (tile_1, tile_2, ...)
                        }
                    }

                    var scope = angular.element('#dashboard').scope();
                    scope.$apply(this);
                } catch (e) {
                    console.log('dragTile: EXCEPTION '); // + e.toString());
                }
            };

            // Draw charts (tile types: donut, column, table) - execute JavaScript to invoke chart service.

            self.createCharts = function () {

                if (self.chartProvider == 'ChartJS') {
                    $('.chartjs-donutchart').each(function () {
                        self.drawDonutChart(this.id);
                    });

                    $('.chartjs-columnchart').each(function () {
                        self.drawColumnChart(this.id);
                    });
                }

                if (self.chartProvider == 'Google') {
                    $('.donutchart').each(function () {
                        self.drawDonutChart(this.id);
                    });

                    $('.columnchart').each(function () {
                        self.drawColumnChart(this.id);
                    });
                }
            }

            // Draw a donut chart

            self.drawDonutChart = function (elementId) {
                ChartService.drawDonutChart(self, elementId);
            }

            // Draw a column chart

            self.drawColumnChart = function (elementId) {
                ChartService.drawColumnChart(self, elementId);
            }

            // Compute layout based on current screen dimensions (self.tablecolumns()) and generate updated tile layout
            // Inputs: self.tablecolumns() ........... number of tiles across.
            // Ouptuts: self.layoutView .............. updated array of tile rows, each element an array of tiles for a row [[ tile1, ... tileN ], ... [ tileX, ... tileZ ] ]

            self.computeLayout = function () {
                if (self.tiles == null) return;

                var numcols = self.tablecolumns();
                if (numcols < 1) numcols = 1;

                var newlayout = [];

                var index = 0;
                var row = [];

                var tileid = 1;

                var tile = null;
                for (var t = 0; t < self.tiles.length; t++) {
                    tile = self.tiles[t];
                    if (index + tile.width > numcols) { // no more room in row. commit row and start new one.
                        newlayout.push(row);
                        row = [];
                        index = 0;
                    }
                    tile.id = tileid.toString();
                    tileid = tileid + 1;
                    row.push(tile);
                    index = index + tile.width;
                }
                if (row.length > 0) {   // commit final row if not empty.
                    newlayout.push(row);
                }

                $scope.tablerows = newlayout.length;

                self.layoutView = newlayout;
            };

            self.layoutView = [];           // set by self.computeLayout

            // Load tile definitions and perform remaining initilization.

            DataService.getTileLayout().then(function (response) {
                self.tiles = response;

                // Generate tile properties id, sizeClass, bgClass, and classes.
                var tile;
                if (self.tiles != null) {
                    for (var t = 0; t < self.tiles.length; t++) {
                        tile = self.tiles[t];
                        tile.id = 'tile-' + (t + 1).toString(); // tile id (tile_1, tile_2, ...)
                        tile.bgClass = "tile-" + tile.color; // tile background color class (tile-red, tile-yellow, ...)
                        tile.sizeClass = "tile_" + tile.width.toString() + "_" + tile.height.toString(); // tile size class (tile_1_1, tile_1_2, tile_2_1, tile_2_2)
                        tile.classes = tile.sizeClass + ' ' + tile.bgClass; // full list of classes to customize tile appearance
                        if (tile.color == 'white' || tile.color == 'yellow' || tile.color == 'gray') {
                            tile.textColor = 'black';
                        }
                        else {
                            tile.textColor = 'white';
                        }
                    }
                }

                self.computeLayout();
                deferCreateCharts();
            });
        }]
});

Revised Dashboard Controller Code with Promise for getTileLayout

MVC Controller

To retreive our dashboard layout from the database, we need a back-end service. We've added an MVC controller to the ASP.NET project named DashboardController. It's Index() function reads dashboard layout from the database and returns it as a Json result. Our JavaScript SQL Server Data Service calls this MVC action in its getTileLayout() function.
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Data.SqlClient;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace Dashboard.controllers
{
    public class DashboardController : Controller
    {
        // /Dashboard .... return dashboard layout as JSON

        public JsonResult Index()
        {
            int dashboardId = -1;

            Dashboard dashboard = new Dashboard()
            {
                DashboardName = "Home",
                Username = "default",   // TODO: check for username, if not found fall back to default
                Tiles = new List<Tile>()
            };
            
            try
            {
                using (SqlConnection conn = new SqlConnection(System.Configuration.ConfigurationManager.AppSettings["Database"]))
                {
                    conn.Open();

                    // Load the default home dashboard.

                    String query = "SELECT DashboardId FROM DashboardLayout WHERE DashboardName='Home' AND Username='default'";

                    using (SqlCommand cmd = new SqlCommand(query, conn))
                    {
                        cmd.CommandType = System.Data.CommandType.Text;
                        using (SqlDataReader reader = cmd.ExecuteReader())
                        {
                            if (reader.Read())
                            {
                                dashboardId = Convert.ToInt32(reader["DashboardId"]);
                            }
                        }
                    }

                    if (dashboardId != -1) // If found a dashboard...
                    {
                        // Load dashboard layout

                        query = @"SELECT prop.* FROM DashboardLayoutTile tile
                                  INNER JOIN DashboardLayoutTileProperty prop  
                                  ON tile.DashboardId=prop.DashboardId
                                  WHERE tile.DashboardId=@DashboardId
                                  ORDER BY prop.Sequence,prop.PropertyName";

                        Tile tile = null;
                        int lastSeq = 0;
                        int seq = 0;
                        using (SqlCommand cmd = new SqlCommand(query, conn))
                        {
                            cmd.CommandType = System.Data.CommandType.Text;
                            cmd.Parameters.AddWithValue("@DashboardId", dashboardId);
                            using (SqlDataReader reader = cmd.ExecuteReader())
                            {
                                while(reader.Read())
                                {
                                    seq = Convert.ToInt32(reader["Sequence"]);
                                    if (seq != lastSeq) // starting a new sequence (tile)
                                    {
                                        lastSeq = seq;
                                        if (tile != null)
                                        {
                                            dashboard.Tiles.Add(tile);
                                        }
                                        tile = new Tile()
                                        {
                                            Sequence = seq,
                                            Properties = new List<TileProperty>()
                                        };
                                    }

                                    tile.Properties.Add(new TileProperty(Convert.ToString(reader["PropertyName"]), Convert.ToString(reader["PropertyValue"])));
                                } // end while have tile property
                                dashboard.Tiles.Add(tile); // add final tile to tiles collection
                            }
                        }
                    }

                    conn.Close();
                }
            }
            catch(Exception ex)
            {
                Console.WriteLine("EXCEPTION: " + ex.Message);
            }

            return Json(dashboard, JsonRequestBehavior.AllowGet);
        }
    }

    #region Dashboard Layout Objects

    public class Dashboard
    {
        public String DashboardName { get; set; }
        public String Username { get; set; }
        public List<Tile> Tiles { get; set; }

        public Dashboard()
        {
            DashboardName = "Home";
            Tiles = new List<Tile>();
        }

        public Dashboard(String name)
        {
            DashboardName = name;
            Tiles = new List<Tile>();
        }
    }

    public class Tile
    {
        public int Sequence { get; set; }
        public List<TileProperty> Properties { get; set; }
    }

    public class TileProperty
    {
        public String PropertyName { get; set; }
        public String PropertyValue { get; set; }

        public TileProperty() { }

        public TileProperty(String name, String value = null) 
        {
            PropertyName = name;
            PropertyValue = value;
        }
    }

    #endregion
}
DashboardController

Index.html Changes

We need to modify our web page, index.html, to load the SQL Server Data Service instead of the Demo Data Service.
        ...
        <!-- Demo data service -->
        <!--<script src="/components/dashboard/demo.data.service.js"></script>-->

        <!-- SQL Server data service -->
        <script src="/components/dashboard/sql.data.service.js"></script>

        <script src="/components/dashboard/dashboard.component.js"></script>
    </body>
</html>
Index.html changes to allow Switching between Data Services

Seeing the Dashboard Run with DB Back-end for Configuration

Now we're ready to run our project with the dashboard configuration coming from our database. When we run it, we get the following dashboard rendered. Previously, changing our dashboard meant changing out our tile structure in JavaScript code. Now, we can make changes to the database to change the dashboard.

Dashboard Rendered from Back-End and DB Dashboard Configuration

Our back end is off to a good start, but it isn't finished yet. Next time, we'll add querying the database to supply tile data.

Summary

Today in Part 3 we accomplished a great deal:

  • We implemented a new tile type, a KPI tile.
  • We changed our too-simple tile rendering scheme to detect window resizing, compute the number of tile units across that will fit, and render tiles in an HTML table.
  • We created the notion of a data service, injected into the dashboard controller.
  • We implemented a simple Demo Data Service, with inline JavaScript tile definitions.
  • We added a database and defined tables for dashboard layout.
  • We implemented a SQL Server Data Service, that retrieves dashboard layout from a back end.
  • We implemented a back-end Dashboard MVC controller which accesses our database. 

Download Source
Dashboard_03.zip
https://drive.google.com/file/d/0B4734rvcufGGaDVFdnpNeW44X1U/view?usp=sharing

Next: An AngularJS Dashboard, Part 4: Bar/Pie Charts and Back-end Data Queries

Sunday, October 1, 2017

An AngularJS Dashboard, Part 2: Counter, Table, Donut Chart, and Column Chart Tiles

NOTE: for best results, view the http: version of this page (else you won't get syntax highlighting).

This is Part 2 in a series on creating a dashboard in AngularJS.

Previously in Part 1, we laid the foundation for a dashboard in Angular. However, all that did was render empty placeholder tiles--colored rectangles, each 1- or 2- units wide and 1- or 2-units tall, with a title. Now, we're going to start creating tiles that do visualizations.

Here in Part 2, we will be creating our first four tiles: a counter tile, a table tile, and two chart tiles (donut chart and colimn chart). Here's a glimpse of what we'll be ending up with:

New Dashboard Tiles

Counter Tile

A counter tile is a simple kind of tile whose purpose is to simply display a number. You might use a counter to display things like Number of Orders, Number of Completed Shipments, Number of Web Visits, or Number of Open Support Tickets. Our tile will devote a large part of its display area to the counter value; and below that, a label giving the number some context.

Tile Definition

Now that we are actually starting to implement tiles, we will need to add a few new properties to the tile objects in our controller's tiles array.
  • type : indicates the type of tile. Our first tile will have type 'counter'
  • value : for a counter tile, a numeric value to display (note: some of the other tile types such as chart tiles, will have complex values for this property).
  • label : for a counter tile, this is the text that appears below the count.
With the above new properties, here our some sample definitions of counter tiles:
self.tiles = [
    {
        title: 'Site Traffic',
        type: 'counter',
        width: 1, height: 1,
        color: 'red',
        value: '1365',
        label: 'Site Visits'
    },
    {
        title: 'Orders',
        type: 'counter',
        width: 1, height: 1,
        color: 'yellow',
        value: '100',
        label: 'Orders'
                    
    },

Template Changes

This simple tile type can be rendered with a small bit of HTML. In our template (dashboard.template.html), in the ng-repeat loop that runs for each tile, we will be inserting our new HTML markup for the counter tile. However, other tile types are coming as well, so we only want this new counter tile markup to be emitted when the current tile type is 'counter'.

The way we achieve this is with Angular's ng-if directive, which will only include an area of markup if its condition evaluates to true. The condition we'll use here is tile.type=='counter'. In order to position well on 1-high and 2-high tiles, we're going to need two variations of our markup. We add an additional condition to ng-if to also take tile.height into account. Now we have two variations of the counter markup for single-high and double-high tiles (lines 10 and 15).
<div id="tile-{{$index+1}}" ng-repeat="tile in $ctrl.tiles" 
        class="tile" ng-class="tile.classes"
        style="overflow: hidden"
        draggable="true" ondragstart="tile_dragstart(event);"
        ondrop="tile_drop(event);" ondragover="tile_dragover(event);">
    <div class="hovermenu"><i class="fa fa-ellipsis-h" aria-hidden="true"></i></div>
    <div style="overflow: hidden; white-space: nowrap">{{tile.title}}</div>
    <div style="position: relative; height: 100%">
        <!-- COUNTER tile -->
        <div ng-if="tile.type=='counter' && tile.height==1" 
                style="text-align: center; position: absolute; left: 0; right: 0; margin: 0 auto; top: 25px">
            <div style="font-size: 72px">{{tile.value}}</div>
            <div style="font-size: 24px">{{tile.label}}</div>
        </div>
        <div ng-if="tile.type=='counter' && tile.height==2"
                style="text-align: center; position: absolute; left: 0; right: 0; margin: 0 auto; top: 135px">
            <div style="font-size: 72px">{{tile.value}}</div>
            <div style="font-size: 24px">{{tile.label}}</div>
        </div>
        <!-- PLACEHOLDER tile -->
        <div ng-if="tile.type=='placeholder'"
                style="position: absolute; top: 0">
        </div>
    </div>
</div> <!-- next tile -->

Seeing It Work: Counter Tile

Expanding the tile definition and template is all we need to do, so we're ready to try out the new counter tile. Here's how it renders when we run the project:

Counter Tiles in Dashboard

As you can see, the counter tiles honored the title, color, width, and height properties that we established last time in Part 1; and, they output the new value and label properties as well.

Let's expand these tiles to larger sizes. We'll try a 2 x 1 red tile and a 1 x 2 yellow tile.

2x1 and 1x2 Counter Tiles in Dashboard

The tiles rendered as we expected them to, but this points out a problem with our current approach of just emitting each tile plus a right margin in the page flow... if the tile sizes are mismatched, there may be gaps (empty spaces) in our dashboard. Eventually, we're going to need a smarter filling algorithm to avoid empty spaces. We won't be worrying about that in today's lesson however.

Table Tile

Next, we'll implement another tile for showing tabular data. A table tile displays a data table, with a vertical scroll bar if necessary. You could use a table tile for things like Pending Orders, Sales Executive Performance, or Monthly Product Sales. Our tile will display an HTML table with alternating row colors. If a column has numeric data, we'd like to be right-aligned.

Tile Definition

The table tile has these properties:
  • type : 'table'
  • value: an array of row items, where each row item is an array of column values. This is a big departure from our prior counter tile, which just had a single number as a value.
  • columns: an array of column items, where a column item is an array containing header text and a data type ('string', 'number', 'boolean'). Type 'number' columns will be right-justified.

With the above new properties, here is a sample definition of a table tile:
{
title: 'Employees',
type: 'table', 
width: 1, height: 1,
color: 'orange',
columns: [['Name', 'string'], ['Salary', 'number'], ['FTE', 'boolean']],
value: [
            ['Mike', 10000, true],
            ['Jim', 8000, false],
            ['Alice', 12500, true],
            ['Bob', 7000, true],
            ['Cheryl', 7000, true],
            ['Don', 7000, true],
            ['Edith', 7000, true],
            ['Frank', 7000, true],
            ['Gary', 7000, true],
            ['Hazel', 7000, true],
            ['Ira', 7000, true],
            ['Jacob', 7000, true],
            ['Loretta', 7000, true],
            ['Mary', 7000, true],
            ['Neil', 7000, true],
            ['Obadiah', 7000, true],
            ['Peter', 7000, true],
            ['Quincy', 7000, true]
        ]
}

Template Changes

In our template, in the ng-repeat loop that runs for each tile, we will be inserting new HTML markup for the table tile. As with the previous counter tile, we'll use ng-if to include the markup only when the current tile type is 'table'.
<!-- TABLE tile -->
<div ng-if="tile.type=='table'"
        style="text-align: left !important; padding: 16px; height: 100%">
    <div style="height: 100%; text-align: left !important">
        <table style="padding-bottom: 52px;">
            <tr>
                <td ng-repeat="col in tile.columns">{{col[0]}}</td>
            </tr>
            <tr ng-repeat="row in tile.value">
                <td ng-repeat="cell in row">
                    <div ng-if="tile.columns[$index][1]=='number'" class="td-right" >{{cell}}</div>
                    <div ng-if="tile.columns[$index][1]!='number'">{{cell}}</div>
                </td>
            </tr>
        </table>
    </div>
The markup uses multiple ng-repeat directives to iterate through the tile data:

  • The first ng-repeat (line 7) iterates through the tile.column array of column headings, which are emitted as a row of header cells.
  • The seccond ng-repeat (line 9) iterates through each row item array in the tile.value property., creating a row. 
  • An inner ng-repeat (line 10) iterates through the row item array, outputing a cell for each value. If the column header for the column is of type 'number', the
    cell is right-aligned.

Seeing It Work: Table Tile

We're ready to see our tile in action. Here's how the table tile renders when we run the project:

Table Tile (1x1) in Dashboard

Adding a Chart Service

Next up, we're going to be getting a bit more ambitious. It's time to tackle chart tiles. These are more complex, and will involve using a Chart API. Our choice of API is the Google Visualization API, which has a lot of capability and is easy to use. You should read the terms of this and any other API you use, but my reading of it is that it is free to use, even for commercial purposes, as long as you honor the terms of service.

With this work it's time to make some structural changes. Up untll now, JavaScript code for our dashboard has resided in the dashboard controller (dashboard.component.js), and our tiles have been simply rendered via markup in our template. Now however we're going to start needing JavaScript functions to do things like render charts. If we add this code to our controller, its going to get long--so we're instead going to put this code into a new service, the ChartService (google.chart.service.js).

The chart service will be injected into our controller by Angular. because we'll be declaring 'ChartService' as a dependency.

The diagram below illustrates how the controller interacts with the injected chart service to render a chart into the DOM with Angular. This approach, another use of dependency injection, will also allow us to be completely modular with our implementation of chart tiles. If we needed to implement a different chart API, only the chart service module would be affected. (We will in fact implement charts with an alternative API later on in this posting, but for now we'll focus on using the Google Visualizaton API.)

Chart Service is injected into Dashboard Controller by Angular

Donut Chart Tile (Google Visualization)

Now we'll create our first chart tile. This will be a donut chart. Compared to the counter tile we first implemented, the donut chart tile will be more challenging:
  • The value property will need to link to a collection of values rather than a single value.
  • Each value will also need a descriptive label
  • In addition to markup in the dashboard template HTML, we'll also need some JavaScript code to execute after the markup is rendered. That code will reside in our new chart service (which in turn will use the Google Visualization API.)

Tile Definition

Our new tile type will have these properties:
  • type: set to 'donut'
  • columns: an array of string labels for each donut slice.
  • value: an array of numeric values, one for each slide. The length of the value array should match the length of the column array. Pie slice colors will be assigned automatically.

 Here's a sample tile definition:
{
    title: 'Time Management',
    type:  'donut',
    width: 2, height: 1,
    color: 'purple',
    columns: ['Work', 'Eat', 'Commute', 'Watch TV', 'Sleep', "Exercise" ],
    value: [11, 2, 2, 2, 7, 4]
},

Template Changes

As with the former tile type, we'll be using ng-if and checking tile.type to only emit donut chart markup when the chart provider is 'Google' and the chart type is 'donut'. You'll note the markup is very simple, just a div with a class of 'donutchart'. This will be replaced with chart markup later on when we apply some JavaScript so the API can work its wonders. We are assigning the class name "donutchart" to the div so that we can easily locate it later when it is time to invoke the API.
<!-- Google DONUT tile -->
<div ng-if="$ctrl.chartProvider=='Google' && tile.type=='donut'"
        style="text-align: center; padding: 16px; height: 100%">
    <div class="donutchart" id="tile-donutchart-{{$index+1}}" style="height: 100%"></div>
</div>

Adding a Service and Rendering the Donut Chart

It's time to create our new chart service, in file google.chart.service.js.

The service contains a chartProvider function which returns the name of the chart provider ("Google"). We can leverage this value in our template to make intelligent decisions about the markup needed.

The service also contains a drawDonutChart function which renders a donut chart in an element. This function takes two parameters: the controller instance that is calling it (this is needed to access tile definitions), and the HTML element where the chart is to be rendered. The code does the following:
  1. Finds the controller tile it is supposed to render.
  2. Converts the tile definition's columns and value property into a Google data table. This will fail if the tile's value property is not in the format expected by the API.
  3. Creates some option settings for the chart. These need to be adjusted based on our four possible tile sizes.
  4. Invokes the Google Visualization API to render a pie chart (a donut chart is merely a pie chart with its center missing).
Here's the chart service code:
'use strict';

// GoogleChartService : ChartService, contains functions for rendering a variety of dashboard chart tiles (Google Visualization API implementation)
//
// drawDonutChart ............. renders a Google Visualization donut chart
// drawColumnChart ............ renders a Google Visualization column chart
// drawTableChart ............. renders a Google Visualization table chart

dashboardApp.service('ChartService', function ($http) {

    var COLOR_RGB_BLUE = 'RGB(69,97,201)';
    var COLOR_RGB_RED = 'RGB(210,54,54)';
    var COLOR_RGB_ORANGE = 'RGB(247,152,33)'
    var COLOR_RGB_GREEN = 'RGB(45,151,34)';
    var COLOR_RGB_PURPLE = 'RGB(148,36,151)';
    var COLOR_RGB_CYAN = 'RGB(56,151,196)';
    var COLOR_RGB_YELLOW = 'RGB(252,209,22)';

    this.chartProvider = function () {
        return 'Google';
    }

    // -------------------- drawDonutChart : Draw a Google Visualization donut chart ------------------

    this.drawDonutChart = function (self, elementId) {
        var index = getTileIndexFromId(elementId);
        var tile = self.tiles[index];

        // Convert the tile's column and value properties (both 1-dimensional arrays) to the data structure Google expects:
        // [ heading1, heading2 ]
        // [ label1, value1 ]
        // [ labelN, valueN ]

        var newValues = [];
        var dataTable = tile.value;
        if (dataTable != null) {
            for (var i = 0; i < dataTable.length; i++) {
                if (i == 0) {
                    newValues.push([ "Label1", "Label2" ]);
                }
                newValues.push([ tile.columns[i], tile.value[i] ]);
            }
            dataTable = newValues;
        }

        if (dataTable === null) {
            dataTable = [];
        }

        try {
            var data = google.visualization.arrayToDataTable(dataTable);

            var options = {
                pieHole: 0.4,
                backgroundColor: 'transparent',
                enableInteractivity: false  // this is to ensure drag and drop works unimpaired on mobile devices
            };

            if (tile.width == 1 && tile.height==1) { /* if 1xn tile, suppress legend */
                options.legend = 'none';
                options.chartArea = { top: 0, left: '100px', width: '95%', height: '75%' }
            }
            else if (tile.width == 2 && tile.height == 1) { /* if 2x1 tile, legend at right */
                options.legend = {
                    position: 'right',
                    maxLines: 10,
                },
                options.legendPosition = 'right';
                options.legendTextStyle = {
                    color: 'white'
                };
                options.chartArea = { top: 0, left: 0, width: '95%', height: '75%' }
            }
            else if (tile.width == 1 && tile.height == 2) { /* if 1x2 tile, legend at top TODO: why doesn't legend appear? */
                options.legend = {
                    position: 'top',
                    maxLines: 10,
                },
                options.legendPosition = 'top';
                options.legendTextStyle = {
                    color: 'white'
                };
            }
            else { /* 2x2 */
                options.legend = {
                    position: 'top',
                    maxLines: 10,
                },
                options.legendPosition = 'top';
                options.legendTextStyle = {
                    color: 'white'
                };
            }

            var chart = new google.visualization.PieChart(document.getElementById(elementId));
            chart.draw(data, options);
        }
        catch (e) {
            console.log('exception ' + e.toString());
        }
    }

    //------------------ getTileIndexFromId : return the tile index of a tile element Id (tile-1 => 0, tile-2 => 1, etc.) -------------------

    // given a tile rendering area id like tile-donut-1, return index (e.g. 0)

    function getTileIndexFromId(elementId) {
        var pos = -1;
        var text = elementId;
        while ((pos = text.indexOf('-')) != -1) {
            text = text.substring(pos + 1);
        }
        var index = parseInt(text) - 1;
        return index;
    }

});

Calling the Service from the Controller

We've seen the service code, but how does it connect up with the controller? We can answer that by looking at the first few lines of revised code for the controller.
angular.module('dashboard').component('dashboard', {
    templateUrl: '/components/dashboard/dashboard.template.html',
    controller: [
        '$scope', '$http', 'ChartService', function DashboardController($scope, $http, ChartService) {
            var self = this;
            var ctrl = angular.element('#dashboard').scope().$$childHead.$ctrl;

            self.title = 'Sample Dashboard';
            self.tiles = [

            self.chartProvider = ChartService.chartProvider();
Notice that the controller now lists 'ChartService' as a dependency. Angular will ensure this service is passed into the controller as variable ChartService. The controller code is now free to call functions of this service, such as ChartService.drawDonutChart(...).

We have a new short function in the controller named createCharts. This function is going to be invoked whenever Angular finishes rendering the template.
// Draw charts (tile types: donut, column, table) - execute JavaScript to invoke chart service.

self.createCharts = function () {

    if (self.chartProvider == 'Google') {
        $('.donutchart').each(function () {
            self.drawDonutChart(this.id);
        });
    }
}

// Draw a donut chart

self.drawDonutChart = function (elementId) {
    ChartService.drawDonutChart(self, elementId);
}
This function in turn calls the ChartService's drawDonutChart function, which causes the chart to be created.

Invoking JavaScript After the Template Renders: Adding a Directive

We now have a service with a function to render our chart, and we've added a controller function to ensure all charts get created--but we need this code to be called right after Angular renders our dashboard template.

How do we achieve this? Unfortunately, there isn't a directive built in to Angular that will help us. So, we'll need to create our own directive. We can do that by adding a little bit of code to our app module, app.module.js.
// Define the 'dashboardApp' module
dashboardApp = angular.module('dashboardApp', [
    'dashboard'
]);

// afterRender directive
// source: http://gsferreira.com/archive/2015/03/angularjs-after-render-directive/

angular.module('dashboardApp')
    .directive('afterRender', ['$timeout', function ($timeout) {
    var def = {
        restrict: 'A',
        terminal: true,
        transclude: false,
        link: function (scope, element, attrs) {
            deferCreateCharts();
        }
    };
    return def;
}]);
This gives us a new directive (after-render) which we can add to our template, dashboard.template.html.
<div>
<div>{{$ctrl.title}} (chart provider: {{$ctrl.chartProvider}})</div>
<span after-render="deferCreateCharts"></span>
<div class="dashboard-panel">
    <div id="tile-{{$index+1}}" ng-repeat="tile in $ctrl.tiles" 
            class="tile" ng-class="tile.classes"
            style="overflow: hidden"
            draggable="true" ondragstart="tile_dragstart(event);"
            ondrop="tile_drop(event);" ondragover="tile_dragover(event);">
        <div class="hovermenu"><i class="fa fa-ellipsis-h" aria-hidden="true"></i></div>
        <div style="overflow: hidden; white-space: nowrap">{{tile.title}}</div>
        <div style="position: relative; height: 100%">
            <!-- COUNTER tile -->
            <div ng-if="tile.type=='counter' && tile.height==1" 
                    style="text-align: center; position: absolute; left: 0; right: 0; margin: 0 auto; top: 25px">
                <div style="font-size: 72px">{{tile.value}}</div>
                <div style="font-size: 24px">{{tile.label}}</div>
            </div>
            <div ng-if="tile.type=='counter' && tile.height==2"
                    style="text-align: center; position: absolute; left: 0; right: 0; margin: 0 auto; top: 135px">
                <div style="font-size: 72px">{{tile.value}}</div>
                <div style="font-size: 24px">{{tile.label}}</div>
            </div>
            <!-- TABLE tile -->
            <div ng-if="tile.type=='table'"
                    style="text-align: left !important; padding: 16px; height: 100%">
                <div style="height: 100%; text-align: left !important">
                    <table style="padding-bottom: 52px;">
                        <tr>
                            <th ng-repeat="col in tile.columns">{{col[0]}}</th>
                        </tr>
                        <tr ng-repeat="row in tile.value">
                            <td ng-repeat="cell in row">
                                <div ng-if="tile.columns[$index][1]=='number'" class="td-right" >{{cell}}</div>
                                <div ng-if="tile.columns[$index][1]!='number'">{{cell}}</div>
                            </td>
                        </tr>
                    </table>
                </div>
            </div>
            <!-- Google DONUT tile -->
            <div ng-if="$ctrl.chartProvider=='Google' && tile.type=='donut'"
                    style="text-align: center; padding: 16px; height: 100%">
                <div class="donutchart" id="tile-donutchart-{{$index+1}}" style="height: 100%"></div>
            </div>
With this last piece in place, we're now ready to try out our new tile.

Seeing it Work: Donut Chart (Google Visualization API)

When we run our project now, we see our donut chart render.

Donut Chart (2x2 tile)

We can see the donut chart is rendering nicely within the tile area, with a legend above that shows the labels for each color. The percentage is shown for each pie slice.

How does this tile look at other sizes? Let's take a look.

Donut Chart (1x1, 2x1)

We can also see that the donut rendering code is considering the tile dimensions and deciding whether there is room for the legend, and where it should go (top or right). In the case of a 1x1 tile, the legend is omitted.

Column Chart Tile (Google Visualization)

We're now going to implement another chart, a column chart (vertical bar chart), again using the Google Visualization API. This will follow the same pattern as the donut chart: namely, a service function to render the chart will invoke the API. The column chart has similar properties to the donut tile:
  • The value property will need to link to an array of labels and value array items.
  • Each value will need a descriptive label
  • We'll need to use an API to generate the chart
In addition to markup in the dashboard template HTML, we'll again need some chart service JavaScript code to execute after the markup is rendered.

Tile Definition

Our new tile will have these properties:
  • type: set to 'column'
  • columns: an array of names for each bar.
  • value: an array of numeric values, one for each bar. The value array and the columns array should be the same length. Bar colors will be assigned automatically.
Here's an example tile definition:

{
    title: 'Precious Metals',
    width: 2, height: 2,
    color: 'blue',
    type: 'column',
    columns: [ 'Copper', 'Silver', 'Gold', 'Platinum' ],
    value: [ 8.94, 10.49, 19.30, 21.45 ]
},

Template Changes

As with the donut chart tile, we'll be inserting a small bit of markup, with the ng-if directive checking for a chart provider of 'Google' and a tile type of 'column'. The div where the chart is to be rendered is assigned a class of 'columnchart', which the JavaScript drawing code will search for.
<!-- Google COLUMN tile -->
<div ng-if="$ctrl.chartProvider=='Google' && tile.type=='column'"
        style="text-align: center; padding: 16px; height: 100%">
    <div class="columnchart" id="tile-columnchart-{{$index+1}}" style="height: 100%"></div>
</div>

Service Code

Just as we added a draw function to the chart service earlier, we'll do that again now. The drawColumnChart function will render a column chart by doing the following:
  1. Finding the tile the element it is passed is part of.
  2. Converting the tile's value data to a form accepted by the Google Visualization API.
  3. Creating options for the chart, and fine-tuning them based on tile size.
  4. Adding code to display labels on each bar.
  5. Invoking the Google Visualization API to draw a column chart.
// -------------------- drawColumnChart : Draw a Google Visualization column chart ------------------

this.drawColumnChart = function (self, elementId) {
    var index = getTileIndexFromId(elementId);
    var tile = self.tiles[index];

    // Convert the tile's column and value properties (both 1-dimensional arrays) to the data structure Google expects:
    // [ heading1, heading2 ]
    // [ label1, value1 ]
    // [ labelN, valueN ]

    var newValues = [];
    var dataTable = tile.value;
    if (dataTable != null) {
        for (var i = 0; i < dataTable.length; i++) {
            if (i == 0) {
                newValues.push(["Element", "Density" ]);
            }
            newValues.push([ tile.columns[i], tile.value[i] ]);
        }
        dataTable = newValues;
    }

    if (dataTable === null) {
        dataTable = [];
    }

    try {
        // Get tile data and expand to include assigned bar colors.

        var colors = [COLOR_RGB_BLUE, COLOR_RGB_RED, COLOR_RGB_ORANGE, COLOR_RGB_GREEN, COLOR_RGB_PURPLE, COLOR_RGB_CYAN, COLOR_RGB_YELLOW];
        var maxColorIndex = colors.length;
        var colorIndex = 0;

        var tileData = dataTable;
        var newTileData = [];
        if (tileData != null) {
            for (var i = 0; i < tileData.length; i++) {
                if (i == 0) {
                    newTileData.push([ tileData[i][0], tileData[i][1], { role: 'style' } ]);
                }
                else {
                    var color = colors[colorIndex++];
                    if (colorIndex >= maxColorIndex) colorIndex = 0;
                    newTileData.push([tileData[i][0], tileData[i][1], color]);
                }
            }
        }

        var data = google.visualization.arrayToDataTable(newTileData);

        var options = {
            colors: ['red', 'yellow', 'blue'],
            backgroundColor: 'transparent',
            legend: 'none',
            vAxis: {
                minValue: 0,
                gridlines: {
                    color: tile.textColor
                },
                textStyle: {
                    color: tile.textColor
                }
            },
            hAxis: {
                minValue: 0,
                gridlines: {
                    color: tile.textColor
                },
                textStyle: {
                    color: tile.textColor
                }
            },
            enableInteractivity: false  // this is to ensure drag and drop works unimpaired on mobile devices
        };

        options.chartArea = { top: 0, left: 0, width: '100%', height: '70%' }

        if (tile.width == 1) { /* if 1xn tile, suppress legend */
            options.chartArea = { top: 0, left: 0, width: '100%', height: '70%' }
        }
        else if (tile.width == 2 && tile.height == 1) { /* if 2x1 tile, legend at right */
            options.chartArea = { top: 0, left: 0, width: '100%', height: '70%' }
        }
        else if (tile.width == 1 && tile.height == 2) { /* if 1x2 tile, legend at top */
            options.chartArea = { top: 0, left: 0, width: '100%', height: '80%' }
        }
        else { /* if 2x2 tile, legend at top */
            options.chartArea = { top: 0, left: 30, width: '100%', height: '80%' }
        }

        var view = new google.visualization.DataView(data); // Create a custom view so values are displayed near the top of each bar.
        view.setColumns([0, 1,
                            {
                                calc: "stringify",
                                sourceColumn: 1,
                                type: "string",
                                role: "annotation"
                            },
                            2]);

        var chart = new google.visualization.ColumnChart(document.getElementById(elementId));
        chart.draw(view, options);
    }
    catch (e) {
        console.log('exception ' + e.toString());
    }
}

Calling the Service from the Controller

We'll update the controller's createCharts function to ensure that all elements of class columnchart are processed via the Chart Service's drawColumnChart function.
// Draw charts (tile types: donut, column, table) - execute JavaScript to invoke chart service.

self.createCharts = function () {

    console.log('controller: self.createCharts()');

    if (self.chartProvider == 'Google') {
        $('.donutchart').each(function () {
            self.drawDonutChart(this.id); 
        });

        $('.columnchart').each(function () {
            self.drawColumnChart(this.id);
        });
    }
}

// Draw a donut chart

self.drawDonutChart = function (elementId) {
    ChartService.drawDonutChart(self, elementId);
}

// Draw a column chart

self.drawColumnChart = function (elementId) {
    ChartService.drawColumnChart(self, elementId);
}

New Includes for our Web Page

In order for everything we've done to work, we need to include a few new things in our web page. We'll need a script tag to include our new chart service, google.chart.service.js. We also need to include a reference to the Google Visualization API. We add the following to the bottom of index.html:
        <script src="Scripts/angular.min.js"></script>
        <script src="app/app.module.js"></script>

        <!-- Google Visualization chart service -->
        <script src="/components/dashboard/google.chart.service.js"></script>
        <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>

        <script src="/components/dashboard/dashboard.component.js"></script>

    </body>
</html>

Seeing it Work: Column Chart (Google Visualization API)

When we run our project now, we see our column chart render.

ColumnChart (2x2 tile)

We can see the column chart is filling the tile area nicely, with a value displayed near top of each vertical bar and labels below the bars. Each bar is in a different color, following the same palette we assigned to the donut chart.

Alternate Chart Tile Implementation using Chart.js

We've now created two chart tiles that work well using the Google Visualization API, and it will be very straightforward to add additional chart types to the chart service and template following the same pattern. But, what if we'd like to use a different API? When we discussed the Chart Service earlier, one reason we gave for this approach was making it easy to support alternative implementations using other APIs. There are a number of reasons why one API might not be a good fit for someone else; you might have a problem with the terms of service, or have specific requirements such as the ability to run offline.

To illustrate how we can change out the implementation of a service, we are going to reimplement the Chart Service, this time using Chart.js. Chart.js is an open source chart library, and unlike Google Visualization it can be included locally and can render charts even when offline.

In order to switch out one chart service for another, we'll need to do the following:
  1. Support identical tile definition structures for the chart tiles. 
  2. Implement a chartjs.chart.service.js chart service that provides the same functions found in google.chart.service.js (chartProvider, drawDonutChart, drawColumnChart). And, we'll need to keep these two implementations in sync as we add new chart functions over time.
  3. Update the template to output appropriate markup for each chart provider / tile type combination. The markup we already have for Google needs to be accompanied by markup that will work for Chart.js.
  4. Update index.html to only include the chart service and API we want to use.
Let's get started.

Chart Service

Below is our Chart.js edition of the chart service.
'use strict';

// ChartJSChartService : ChartService, contains functions for rendering a variety of dashboard chart tiles (chart.js implementation)
//
// drawDonutChart ................. renders a Chart.js donut chart
// draweDonutChart ............. renders a Google Visualization donut chart

dashboardApp.service('ChartService', function ($http) {

    var COLOR_RGB_BLUE = 'RGB(69,97,201)';
    var COLOR_RGB_RED = 'RGB(210,54,54)';
    var COLOR_RGB_ORANGE = 'RGB(247,152,33)'
    var COLOR_RGB_GREEN = 'RGB(45,151,34)';
    var COLOR_RGB_PURPLE = 'RGB(148,36,151)';
    var COLOR_RGB_CYAN = 'RGB(56,151,196)';
    var COLOR_RGB_YELLOW = 'RGB(252,209,22)';

    this.chartProvider = function () {
        return 'ChartJS';
    }

    // -------------------- drawDonutChart : Draw a Chart.js donut chart --------------------

    this.drawDonutChart = function (self, elementId) {

        var index = getTileIndexFromId(elementId);
        var tile = self.tiles[index];

        var ctx = document.getElementById(elementId).getContext('2d');

        var options = {
            responsive: true,
            maintainAspectRatio: false,
            legend: {
                display: 'true',
                position: 'top',
                labels: {
                    fontColor: tile.textColor
                }
            },
            title: { display: false },
            animation: {
                //    animateScale: true,
                //    animateRotate: true
                duration: 500,
                easing: "easeOutQuart",
                onComplete: function () {       // Add labels and percent over slices
                    var ctx = this.chart.ctx;
                    ctx.font = Chart.helpers.fontString(Chart.defaults.global.defaultFontFamily, 'normal', Chart.defaults.global.defaultFontFamily);
                    ctx.textAlign = 'center';
                    ctx.textBaseline = 'bottom';

                    this.data.datasets.forEach(function (dataset) {

                        for (var i = 0; i < dataset.data.length; i++) {
                            var model = dataset._meta[Object.keys(dataset._meta)[0]].data[i]._model,
                                total = dataset._meta[Object.keys(dataset._meta)[0]].total,
                                mid_radius = model.innerRadius + (model.outerRadius - model.innerRadius) / 2,
                                start_angle = model.startAngle,
                                end_angle = model.endAngle,
                                mid_angle = start_angle + (end_angle - start_angle) / 2;

                            var x = mid_radius * Math.cos(mid_angle);
                            var y = mid_radius * Math.sin(mid_angle);

                            ctx.fillStyle = tile.textColor;
                            var pct = Math.round(dataset.data[i] / total * 100);
                            var percent = String(pct) + "%";

                            if (pct >= 5) {
                                ctx.fillText(dataset.data[i], model.x + x, model.y + y);
                                // Display percent in another line, line break doesn't work for fillText
                                ctx.fillText(percent, model.x + x, model.y + y + 15);
                            }
                        }
                    });
                }
            }
        };

        if (tile.width == 1 && tile.height == 1) { // 1x1 tile: hide legend
            options.legend = { display: false };
        }
        else if (tile.width == 2 && tile.height == 1) { // 2x1 tile: legend to right of donut chart
            options.legend = {
                display: 'true',
                position: 'right',
                labels: {
                    fontColor: tile.textColor
                }
            };
        }

        var donutChart = new Chart(ctx, {
            type: 'doughnut',
            options: options,
            data: {
                labels: tile.columns,
                datasets: [{
                    data: tile.value,
                    backgroundColor: [COLOR_RGB_BLUE, COLOR_RGB_RED, COLOR_RGB_ORANGE, COLOR_RGB_GREEN, COLOR_RGB_PURPLE, COLOR_RGB_CYAN, COLOR_RGB_YELLOW],
                    borderColor: tile.textColor,
                    borderWidth: 1
                }]
            }
        });
    }

    // -------------------- drawColumnChart : Draw a Chart.js column (vertical bar) chart --------------------

    this.drawColumnChart = function (self, elementId) {

        var index = getTileIndexFromId(elementId);
        var tile = self.tiles[index];

        var ctx = document.getElementById(elementId).getContext('2d');

        var options = {
            defaultFontColor: tile.textColor,
            responsive: true,
            maintainAspectRatio: false,
            legend: {
                display: false,
            },
            title: { display: false },
            scales: {
                xAxes: [{
                    color: tile.textColor,
                    gridLines: {
                        offsetGridLines: true,
                        color: 'transparent', // tile.textColor,
                        zeroLineColor: tile.textColor
                    },
                    ticks: {
                        fontColor: tile.textColor
                    }
                }],
                yAxes: [{
                    display: true,
                    gridLines: {
                        offsetGridLines: true,
                        color: tile.textColor,
                        zeroLineColor: tile.textColor
                    },
                    ticks: {
                        beginAtZero: true,
                        //max: 100,
                        min: 0,
                        color: tile.textColor,
                        fontColor: tile.textColor
                    }
                }]
            }
        };

        var columnChart = new Chart(ctx, {
            type: 'bar',
            options: options,
            data: {
                labels: tile.columns,
                datasets: [{
                    //label: '# of Votes',
                    data: tile.value,
                    backgroundColor: [COLOR_RGB_BLUE, COLOR_RGB_RED, COLOR_RGB_ORANGE, COLOR_RGB_GREEN, COLOR_RGB_PURPLE, COLOR_RGB_CYAN, COLOR_RGB_YELLOW],
                    borderColor: tile.textColor,
                    borderWidth: 0
                }]
            }
        });
    }

    //------------------ getTileIndexFromId : return the tile index of a tile element Id (tile-1 => 0, tile-2 => 1, etc.) -------------------

    // given a tile rendering area id like tile-donut-1, return index (e.g. 0)

    function getTileIndexFromId(elementId) {
        var pos = -1;
        var text = elementId;
        while ((pos = text.indexOf('-')) != -1) {
            text = text.substring(pos + 1);
        }
        var index = parseInt(text) - 1;
        return index;
    }

});

Template Changes

We're adding new markup with ng-if directives to generate HTML for donut charts and column charts when the chart provider is 'ChartJS'. Note that Chart.js uses a canvas element, whereas Google used a div element.
<!-- Chart.js DONUT tile -->
<div ng-if="$ctrl.chartProvider=='ChartJS' && tile.type=='donut'"
        style="text-align: center; padding: 16px; height: 100%">
    <canvas class="chartjs-donutchart" id="tile-canvas-{{$index+1}}" style="margin: 0 auto; width: 90%; max-width: 90%; height: auto; max-height: 80%"></canvas>
</div>
<!-- Chart.js COLUMN tile -->
<div ng-if="$ctrl.chartProvider=='ChartJS' && tile.type=='column'"
        style="text-align: center; padding: 16px; height: 100%">
    <canvas class="chartjs-columnchart" id="tile-canvas-{{$index+1}}" style="margin: 0 auto; width: 90%; max-width: 90%; height: auto; max-height: 80%" ng-if="tile.width==1 && tile.height==1"></canvas>
    <canvas class="chartjs-columnchart" id="tile-canvas-{{$index+1}}" style="margin: 0 auto; width: 90%; max-width: 90%; height: auto; max-height: 75%" ng-if="tile.width==2 && tile.height==1"></canvas>
    <canvas class="chartjs-columnchart" id="tile-canvas-{{$index+1}}" style="margin: 0 auto; width: 90%; max-width: 90%; height: auto; max-height: 85%" ng-if=" tile.height==2"></canvas>
</div>

Web Page Changes

Lastly, we need to comment out the web page includes of the google.chart.service.js file and the Google Visualization API. These will be replaced with the chartjs.chart.service.js file and the Chart.js API.
        <script src="Scripts/angular.min.js"></script>
        <script src="app/app.module.js"></script>

        <!-- Google Visualization chart service -->
        <!--<script src="/components/dashboard/google.chart.service.js"></script>
        <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>-->

        <!-- Chart.js chart service -->
        <script src="/components/dashboard/chartjs.chart.service.js"></script>
        <script src="/Scripts/Chart.bundle.js" type="text/javascript"></script>

        <script src="/components/dashboard/dashboard.component.js"></script>

    </body>

Dashboard Rendered with Chart.js

Let's see how things look with our Chart.js chart service rendering chart tiles. When we run the project, this is what we get:

Dashboard with Chart.js chart service

For comparison, below is how the same dashboard looked when the Google chart service was used.

Dashboard with Google Visualization chart service

If you compare the two, you'll see we get good, equivalent (but not identical) charts. We have demonstrated that our chart implementation is truly modular.

A Word on Filling Tiles Well

Filling tiles is tricky. Your tile might get a lot of data to display or a little. A chart tile might get a large number of data points with a large legend, or hardly any data. Your tile needs to do all it can to render well, on all four tile sizes. In the code we've seen so far, we've at times had to use conditions in the template HTML and/or the JavaScript service code to do different things based on tile size.

This is even more difficult t when you are using an API to render your tile. For example, the Google Visualization API is really powerful. But, it has eccentricities and the decisions it makes about where to put things and where to leave extra blank space might not mesh well with your expecations. In the charts covered here in Part 2, I easily spent the majority of my time experiementing with parameters in the Google Visualization API in order to gain more control over the placement and appearance of charts--and was only partially successful. There are still some chart tiles in some sizes that aren't great, but I'm limited in how much control I have over what the API does.

Summary

We did quite a bit here in Part 2, including the following:

  • Implemented a new counter tile.
  • Used Angular's ng-if directive to conditionally include markup in the template based on tile type.
  • Implemented a new table tile.
  • Added a new module, the chart service (google.chart.service.js), and injected it into the dashboard controller.
  • Added a custom directive, after-render, so that chart code is called automatically after the template is rendered by Angular.
  • Implemented a new donut chart tile using the Google Visualization API.
  • Implemented a new column chart tile using the Google Visualization API.
  • Added a second edition of ChartService that uses Chart.js (chartjs.chart.service.js), to demonstrate the modularity of depdendency injection and the wisdom of separating chart implementation into a service.
  • Implemented donut chart and column chart in the Chart.js chart service.
  • Added JQuery UI Touch-Punch so that drag-and-drop will work on mobile devices.
New Tiles
type Properties value columns Dependencies
counter label, value number N/A N/A
table columns, value [ [ row-1 val1, ... valM ], ...[ row-N val1, ... valM ] ] [ [ heading1, type1 ], ... [headingM, typeM ] ] N/A
donut columns, value [ number1, ... numberN ] [ heading1, ... headingM] ] ChartService
column columns, value [ number1, ... numberN ] [ heading1, ... headingM ] ChartService

The dashboard is starting to look real, but there's still much to do. Going forward, we need to:
  • More intelligently fill the dashboard area to avoid empty spaces
  • Read the tile data from a data source instead of being hard-coded in the JavaScript code
  • Use a saved dashboard layout, and persist user modifications to the layout
  • Implement tile actions, such as adding a new tile, editing an existing tile, or deleting a tile
In the next lesson, we'll get started on some of these matters.