Monday, 25 May 2015

AngularJS - preloading whole project / displaying loading image during AngularJS's bootstrap

It is very quick post but useful. I'll demonstrate how to display loading image before and during AngularJS's first load of the project - we do not want to use ng-cloack directive in this case. The trick is simple - the main goal is to divide project into two div elements - one for loading image and the other for the whole rest of the project. The first one will be displayed initially but the second one will be hidden using CSS. Because there is no AngularJS initially and we want to access the first div with the loading image to hide it when the app loads the only option is to define a directive restricted as a class. Consider following code:

html-template

   <div class="jumbotron vertical-center app-loading">  
     <div class="container-fluid">  
       <div class="row text-center">  
         <img src="~/Public/release/images/preloader.gif">  
       </div>  
       <div class="row text-center">  
         Application is loading. Please wait...  
       </div>  
     </div>  
   </div>  
   <div class="container-fluid" id="appContainer" style="display:none;">  
     <div class="col-xs-12" ng-view></div>  
   </div>  

app-loading directive

 define(['directives/directives', 'angular'], function (directives, angular) {  
   'use strict';  
   directives.directive("appLoading", [function () {  
     return ({  
       link: link,  
       restrict: "C"  
     });  
     function link(scope, element, attributes) {  
       element.remove();  
       scope = element = attributes = null;  
       angular.element('#appContainer').show();  
     }  
   }]);  
 });  

This solves our problem. Once AngularJS fully bootstraps on our application the appLoading directive is being executed, preloader hidden and contents of the app from ng-view displayed (could be a controller or something else).

AngularJS - simple image switcher / cycler / swapper with preload

This time, taking previous post into consideration, I'll show basic web control build as a directive. It will consist of a single image (being preloaded) which will be switched to the next one from the list of all enclosed images to render. We will specify base delay for switching the images, list of images and some other stuff. The main dependency of this new directive is the preloaded-image directive from previous post. This control is to be used across the project since it is the directive. Here is the code:

image-cycler


 define(['directives/directives', 'angular', 'lodash'], function (directives, angular, _) {  
   'use strict';  
   directives.directive('imageCycler', ['$log', 'ApplicationConstants', '$timeout', '$interval', '$compile', '$window',  
     function ($log, ApplicationConstants, $timeout, $interval, $compile, $window) {  
       return {  
         restrict: 'E',  
         scope: {  
           containerId: '@',  
           images: '=',  
           baseDelay: '@',  
           external: '@',  
           placeholderText: '@',  
           placeholderWidth: '@',  
           placeholderHeight: '@',  
           tooltipText: '@',  
         },  
         templateUrl: '/public/js/directives/templates/imageCycler.html',  
         link: function (scope, element, attrs) {  
           scope.isImagePreloading = false;  
           var delay = parseInt(scope.baseDelay) + (($window.Math.random() * parseInt(scope.baseDelay)) | 0);  
           scope.$on(ApplicationConstants.events.imagePreloadStart, function (event, imageId) {  
             $timeout(function () {  
               if (_.has(scope.images, imageId)) {  
                 scope.isImagePreloading = true;  
               }  
             });  
           });  
           scope.$on(ApplicationConstants.events.imagePreloadEnd, function (event, imageId) {  
             $timeout(function () {  
               if (_.has(scope.images, imageId)) {  
                 scope.isImagePreloading = false;  
               }  
             });  
           });  
           scope.$watch('isImagePreloading', function (newValue, oldValue) {  
             $log.debug(newValue, oldValue);  
             if (newValue === true) {  
               $timeout(scope.updateImage, delay);  
             }  
           });  
           var currentImage = 0;  
           scope.updateImage = function () {  
             var baseUrl = '/public/'  
             switch (ApplicationConstants.env) {  
               case 'release':  
                 baseUrl += 'release/images/';  
                 break;  
               case 'dev':  
                 baseUrl += 'js/images/';  
                 break;  
             }  
             if (scope.external) {  
               baseUrl = '';  
             }  
             var imageIds = _.keys(scope.images);  
             if (scope.isImagePreloading) {  
               return;  
             }  
             if (currentImage === imageIds.length) {  
               currentImage = 0;  
             }  
             angular.element('#imageContainer-' + scope.containerId).empty();  
             var preloadedImage = '<preloaded-image image-id="' + imageIds[currentImage] + '" image-src="' + baseUrl + scope.images[imageIds[currentImage]] + '" placeholder-width="' + scope.placeholderWidth + '" placeholder-height="' + scope.placeholderHeight + '" placeholder-text="' + scope.placeholderText + '" tooltip="' + scope.tooltipText + '" tooltip-placement="bottom" tooltip-animation="false" tooltip-class="default"></preloaded-image>'  
             angular.element('#imageContainer-' + scope.containerId).append(preloadedImage)  
             $compile(angular.element('#imageContainer-' + scope.containerId).contents())(scope);  
             ++currentImage;  
           };  
           $timeout(function () {  
             scope.updateImage();  
           });  
         }  
       };  
     }]);  
 });  

As you can see in the code - it depends on the events broadcasted from preloaded-image directive from previous post and running $timeout with delay when image is preloaded / displayed. It inserts the new preloaded image directive's html to the current element and then compiles it allowing angular do it's work.

image-cycler-tpl


 <div id="imageContainer-{{containerId}}"></div>  

usage: controller


 define(['controllers/controllers'], function (controllers) {  
   'use strict';  
   controllers.controller('IndexCtrl', ['$scope', '$location', '$window', function ($scope, $location, $window) {  
     $scope.Math = $window.Math;  
     $scope.images = {  
       'img_1': 'img_1.gif',  
       'img_2': 'img_2.gif',  
       'img_3': 'img_3.gif',  
       'img_4': 'img_4.gif',  
       'img_5': 'img_5.gif',  
       'img_6': 'img_6.gif',  
       'img_7': 'img_7.gif'  
     };  
   }]);  
 });  

usage: view


 <div class="panel-body">  
     <image-cycler container-id="images-cycler" images="images" base-delay="10000" preloader-text="image" placeholder-width="300" placeholder-height="250" tooltip-text="This application helps you plan and carry out your dietary plans."></image-cycler>  
 </div>  


Wednesday, 20 May 2015

Working AngularJS Giphy API integration + preloading GIF images directive


This post describes how to integrate Giphy's API with AngularJS framework. As an example I'll show implementation of a AngularJS's directive which will provide random Giphy's image specified by tags to the end user with preload.

From Giphy's API documentation one can determine requirements for the environment of the API. Following items are worth noticing:
  • apiUrl = http://api.giphy.com/v1/gifs/
  • apiKey = dc6zaTOxFJmzC -> public beta key for testing
  • apiRandomEndpoint = random?api_key=dc6zaTOxFJmzC&tag=tag1+tag2
  • notification about CORS requirement for making requests to the endpoints.
Please note that the apiUrl uses http and redirects to another addres also in http. Because we want to use the https we will configure the redirect URL from the original api URL to be via https. The plan is straightforward - the directive for displaying the random gif, provider for obtaining giphy's image details given the request, embedded as $resource using jsonp for CORS. In my setup I use grunt, bower, requirejs together with angular so the code included might be strange at first sight but easily transplantable.

ApplicationConstants config for the angular's app's module - nothing fancy here - just the config properties of the giphy's API and default search tags.

 define(['factories/factories', 'angular'], function (factories, angular) {  
   'use strict';  
   factories.factory('ApplicationConstants', ['$window', function ApplicationConstants($window) {  
     return {  
       'env': $window.location.href.indexOf('localhost') > -1 ? 'dev' : 'release',  
       'giphyDefaultRandomQuery': 'bicycle',  
       'giphyApiUrl': $window.location.href.indexOf('localhost') > -1 ? 'http://tv.giphy.com/v1/gifs/' : 'https://tv.giphy.com/v1/gifs/',  
       'giphyApiKey': 'dc6zaTOxFJmzC',  
           };  
   }]);  
 });  

The provider for the data of the giphy's image given the configuration and using jsonp for CORS. Notice callback=JSON_CALLBACK  added to the endpoint's URL and other configuration options for idividual objects. Our aim is to obtain $resource as the output to be manipulated later using angular's promises API in the directive.

 define(['providers/providers'], function (providers) {  
   'use strict';  
   providers.provider('giphyRandom', function giphyRandomProvider() {  
     this.$get = ['$resource', function ($resource) {  
       return {  
         query: '',  
         setQuery: function (query) {  
           this.query = query;  
         },  
         invoke: function (giphyApiUrl, giphyApiKey, giphyDefaultRandomQuery) {  
           var query = this.query;  
           var queryToRun;  
           if (!query) {  
             queryToRun = encodeURIComponent(giphyDefaultRandomQuery);  
           } else {  
             queryToRun = encodeURIComponent(query);  
           }  
           return $resource(  
             giphyApiUrl + 'random?api_key=' + giphyApiKey + '&tag=' + queryToRun + '&callback=JSON_CALLBACK',  
             {},  
             {  
               jsonpQuery: {  
                 method: 'JSONP',  
                 transformResponse: function (data, headers) {  
                   //Here is possible to restict the data with only required properties but we want whole giphy's model.  
                   //console.log(data);  
                   return data;  
                 },  
                 responseType: 'json',  
                 headers: { 'Content-Type': 'application/json' }  
               },  
             }  
           );  
         }  
       };  
     }];  
   });  
 });  

Next, we want to specify our directive for the giphy's image. It'll utilize mainly the provider and the application's constants to configure the provider. The data is read from the obtained $resource using angular's promises API. As you can see, I'm using lodash library here.

 define(['directives/directives', 'lodash'], function (directives, _) {  
   'use strict';  
   directives.directive('giphyRandomImage', ['$log', 'giphyRandom', 'ApplicationConstants', '$timeout', '$compile', function ($log, giphyRandomProvider, ApplicationConstants, $timeout, $compile) {  
     return {  
       restrict: 'E',  
       transclude: true,  
       templateUrl: "/public/js/directives/templates/giphyRandomImage.html",  
       scope: {  
         query: '@'  
       },  
       link: function (scope, element, attr) {  
         //giphy's model  
         scope.giphy = {};  
         if (scope.query) {  
           giphyRandomProvider.setQuery(scope.query);  
         } else {  
           giphyRandomProvider.setQuery(ApplicationConstants.giphyDefaultRandomQuery);  
         }  
         var giphyRandom = giphyRandomProvider.invoke(  
           ApplicationConstants.giphyApiUrl,  
           ApplicationConstants.giphyApiKey,  
           ApplicationConstants.giphyDefaultRandomQuery).jsonpQuery();  
         giphyRandom.$promise.then(function (giphyData) {  
           $timeout(function(){  
             _.assign(scope.giphy, { 'fixed_height_downsampled_height': giphyData.data.fixed_height_downsampled_height });  
             _.assign(scope.giphy, { 'fixed_height_downsampled_url': giphyData.data.fixed_height_downsampled_url });  
             _.assign(scope.giphy, { 'fixed_height_downsampled_width': giphyData.data.fixed_height_downsampled_width });  
             _.assign(scope.giphy, { 'fixed_height_small_height': giphyData.data.fixed_height_small_height });  
             _.assign(scope.giphy, { 'fixed_height_small_still_url': giphyData.data.fixed_height_small_still_url });  
             _.assign(scope.giphy, { 'fixed_height_small_url': giphyData.data.fixed_height_small_url });  
             _.assign(scope.giphy, { 'fixed_height_small_width': giphyData.data.fixed_height_small_width });  
             _.assign(scope.giphy, { 'fixed_width_downsampled_height': giphyData.data.fixed_width_downsampled_height });  
             _.assign(scope.giphy, { 'fixed_width_downsampled_url': giphyData.data.fixed_width_downsampled_url });  
             _.assign(scope.giphy, { 'fixed_width_downsampled_width': giphyData.data.fixed_width_downsampled_width });  
             _.assign(scope.giphy, { 'fixed_width_small_height': giphyData.data.fixed_width_small_height });  
             _.assign(scope.giphy, { 'fixed_width_small_still_url': giphyData.data.fixed_width_small_still_url });  
             _.assign(scope.giphy, { 'fixed_width_small_url': giphyData.data.fixed_width_small_url });  
             _.assign(scope.giphy, { 'fixed_width_small_width': giphyData.data.fixed_width_small_width });  
             _.assign(scope.giphy, { 'id': giphyData.data.id });  
             _.assign(scope.giphy, { 'image_frames': giphyData.data.image_frames });  
             _.assign(scope.giphy, { 'image_height': giphyData.data.image_height });  
             _.assign(scope.giphy, { 'image_mp4_url': giphyData.data.image_mp4_url });  
             _.assign(scope.giphy, { 'image_original_url': giphyData.data.image_original_url });  
             _.assign(scope.giphy, { 'image_url': giphyData.data.image_url });  
             _.assign(scope.giphy, { 'image_width': giphyData.data.image_width });  
             _.assign(scope.giphy, { 'type': giphyData.data.type });  
             _.assign(scope.giphy, { 'url': giphyData.data.url });  
             scope.$apply();  
             $compile(element.contents())(scope);  
           });  
         }).catch(function (error) {  
           $log.debug("Error getting Giphy Resource's Data", error);  
         });  
       }  
     };  
   }]);  
 });  

Now, having the directive it is required in our case to have the template for it (as you can see I'm using AngularUIBootstrap attributes here):

 <div>  
   <preloaded-image   
            image-id="{{giphy.id}}"   
            image-src="{{giphy.image_url}}"   
            placeholder-text="giphy"  
            placeholder-width="300"   
            placeholder-height="150"  
            tooltip="giphy.com"  
            tooltip-placement="bottom"  
            tooltip-animation="false"  
            tooltip-class="default">  
   </preloaded-image>  
 </div>  

As you probably have noticed, there are two new directives specified in the template, preloaded-image, Preloaded-image directive first:

 define(['directives/directives', 'angular'], function (directives, angular) {  
   'use strict';  
   directives.directive('preloadedImage', ['$log', '$document', '$timeout', '$window', 'ApplicationConstants', '$rootScope', '$compile',  
     function ($log, $document, $timeout, $window, ApplicationConstants, $rootScope, $compile) {  
       return {  
         restrict: 'E',  
         scope: {  
           imageId: '@',  
           imageSrc: '@',  
           placeholderWidth: '@',  
           placeholderHeight: '@',  
           placeholderText: '@',  
         },  
         templateUrl: '/public/js/directives/templates/preloadedImage.html',  
         link: function (scope, element, attrs) {  
           scope.imageMimeType = '';  
           scope.showPlaceholder = false;  
           scope.showLoadingImage = true;  
           scope.showProgressBar = false;  
           scope.progressBarValue = 0;  
           scope.resolveImageMimeType = function () {  
             var parts = [];  
             parts = scope.imageSrc.split('.');  
             if (parts.length === 0 && !(parts[parts.length - 1] === 'jpg' || parts[parts.length - 1] === 'png' || parts[parts.length - 1] === 'gif')) {  
               $log.debug('Image error', 'Wrong image url - not returning proper image of jpg, gif, png.');  
             } else {  
               switch (parts[parts.length - 1]) {  
                 case 'jpg':  
                   return 'image/jpeg';  
                 case 'gif':  
                   return 'image/gif';  
                 case 'png':  
                   return 'image/png';  
               }  
             }  
           };  
           scope.onLoadStart = function () {  
             scope.$apply(function () {  
               $rootScope.$broadcast(ApplicationConstants.events.imagePreloadStart, scope.imageId);  
               scope.showLoadingImage = false;  
               scope.showProgressBar = true;  
             });  
           };  
           scope.onProgress = function (e) {  
             scope.$apply(function () {  
               scope.progressBarValue = e.loaded / e.total * 100;  
             });  
           };  
           scope.showImage = function () {  
             var image = $document[0].createElement('img');  
             image.className = 'img-responsive img-rounded center-block';  
             image.src = 'data:' + scope.imageMimeType + ';base64,' + scope.xhrBase64DataEncode(xhr.responseText);  
             element.append(image);  
           };  
           scope.onLoadEnded = function () {  
             scope.$apply(function () {  
               $rootScope.$broadcast(ApplicationConstants.events.imagePreloadEnd, scope.imageId);  
               scope.showProgressBar = false;  
               scope.showImage();  
             });  
           };  
           scope.onError = function () {  
             angular.element('#placeholder-' + scope.imageId).attr('ng-src', 'http://placehold.it/' + scope.placeholderWidth + 'x' + scope.placeholderHeight + '&text=' + scope.placeholderText);  
             scope.$apply(function () {  
               $rootScope.$broadcast(ApplicationConstants.events.imagePreloadEnd, scope.imageId);  
               scope.isPreloading = false;  
               scope.showPlaceholder = true;  
             });  
           };  
           var xhr = new $window.XMLHttpRequest();  
           xhr.onloadstart = scope.onLoadStart;  
           xhr.onprogress = scope.onProgress;  
           xhr.onload = scope.onLoadEnded;  
           xhr.onerror = scope.onError;  
           scope.loadImage = function () {  
             $timeout(function () {  
               xhr.open("GET", scope.imageSrc, true);  
               xhr.overrideMimeType('text/plain; charset=x-user-defined');  
               xhr.send();  
             });  
           };  
           scope.xhrBase64DataEncode = function (inputStr) {  
             var b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";  
             var outputStr = "";  
             var i = 0;  
             while (i < inputStr.length) {  
               //all three "& 0xff" added below are there to fix a known bug   
               //with bytes returned by xhr.responseText  
               var byte1 = inputStr.charCodeAt(i++) & 0xff;  
               var byte2 = inputStr.charCodeAt(i++) & 0xff;  
               var byte3 = inputStr.charCodeAt(i++) & 0xff;  
               var enc1 = byte1 >> 2;  
               var enc2 = ((byte1 & 3) << 4) | (byte2 >> 4);  
               var enc3, enc4;  
               if (isNaN(byte2)) {  
                 enc3 = enc4 = 64;  
               }  
               else {  
                 enc3 = ((byte2 & 15) << 2) | (byte3 >> 6);  
                 if (isNaN(byte3)) {  
                   enc4 = 64;  
                 }  
                 else {  
                   enc4 = byte3 & 63;  
                 }  
               }  
               outputStr += b64.charAt(enc1) + b64.charAt(enc2) + b64.charAt(enc3) + b64.charAt(enc4);  
             }  
             return outputStr;  
           };  
           $timeout(function () {  
             scope.imageMimeType = scope.resolveImageMimeType();  
             scope.loadImage();  
           });  
         }  
       };  
     }]);  
 });  

Now, the template for the preloader directive:

 <div id="{{imageId}}">  
   <loading-image id="loadingImage-{{imageId}}" ng-show="showLoadingImage"></loading-image>  
   <progressbar id="progressbar-{{imageId}}" ng-show="showProgressBar" value="progressBarValue" typeof="success">{{progressBarValue}}%</progressbar>  
   <img id="placeholder-{{imageId}}" ng-show="showPlaceholder" class="img-responsive img-rounded center-block" />  
 </div>  

Note: I won't enclose here the loading-image directive because it is just a simple AJAX animated loading image to be chosen from web.

Finally, here is the usage of this all somewhere else, like for example in HomeController's view of the angular's application using bootstrap's panel:

  <div class="panel panel-default">  
       <div class="panel-heading">  
         <h3 class="panel-title">Food and Diet</h3>  
       </div>  
       <div class="panel-body">  
         <giphy-random-image query="food"></giphy-random-image>  
       </div>  
  </div>  

First post

New beginnings. This blog was concieved as the place to post various things here related to the blog's topic, interesting problems which I have encoutered and solved, technology descriptions, development practices and so on. I hope soon I'll be able to post here something more useful for others than this introduction.