A client of mine builds a lot of mapping applications using the ESRI API. The ESRI HTML5 Map components are built on top of the DOJO toolkit, a JavaScript framework and UI library. DOJO has its' own way of doing things. My client likes the Angular approach better than DOJO, so they wanted to be able to use the DOJO Mapping component as an Angular directive. How do we do that?
There are lots of blog posts on using the ESRI Mapping components inside an Angular application. This was the best one I found, however I could not find any ready to go samples. This article intends to explain exactly my approach to make the DOJO component work as an Angular directive.
Why is there a problem?
DOJO is JavaScript code, right? Any JavaScript code can be wrapped up in an Angular directive, right? Why does the ESRI mapping component present problems? The problem is that DOJO uses a “load on demand” approach, and that brings out issues where the AngularJS Directive is created in the browser before the DOJO object is loaded. Getting Angular and DOJO to sync up was the root of my problem.
Create the Main Index
The main index will be an HTML page that will display the map using the Angular directive we will create. First create a basic outline:
2<head lang="en">
3 <meta charset="UTF-8">
4 <link rel="stylesheet" href="http://js.arcgis.com/3.9/js/esri/css/esri.css" />
5 <script src="http://js.arcgis.com/3.9/"></script>
6 <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.0-beta.10/angular.min.js"></script>
7 <script src="js/ESRIDirective.js" ></script>
8</head>
9<body >
10</body>
11</html>
There are four important elements of the HTML header. The first is a link tag to load the ESRI style sheet. This is loaded directly from the ESRI servers. The second loads the ESRI libraries, also loaded from the ESRI servers. The third library loads the Angular library from the Google CDN. The final script tag will load our local ESRIDirective.js file. This directive will include the finished Angular library, which will be built later in this article.
Next, create an AngularJS module and controller for testing the application:
2 var mapTest = angular.module('mapTest',['map']);
3 mapTest.controller('mapTestController',['$scope', function($scope){
4 $scope.title = "Hello Map";
5 }]);
6</script>
This creates a new AngularJS Module named mapTest. One custom directive is passed into it, named map. This is the directive that will be defined in the ESRIDirective.js script. A single controller is created, named mapTestController. It creates a single variable in the $scope, named title.
We flesh out the body of the HTML page to load the map:
2 <div ng-controller="mapTestController">
3 <h1>{{title}}</h1>
4 <esri-map zoom="4" basemap="streets" id="mapID">
5 </esri-map>
6 </div> -
7</body>
I modified the body tag to add the ngApp directive on it. Then I added a div which points to the mapTestController with the ngController directive. The title from the directive's $scope is displayed. Then the map directive is used. Three attributes are specified directly on the esriMap directive: zoom, basemap, and id. These relate to specifics of the ESRI mapping API.
Trying to load this app in a browser will just give you errors, because the ESRIDirective.js file has not yet been created. We can tackle that next.
Write the JavaScript Code
Create the JavaScript file ESRIDirective.js in a js directory relative to the main index file. This is the file that will contain the Angular mapping directive based on the ESRI DOJO component. First create an Angular module:
When you want to create Angular directives that can be shared across different modules, it is a common approach to create the directive in a separate module. The module is passed into the main angular application as part of the dependency array.
Next, I want to create a mapObjectWrapper:
2 this.map = undefined
3}
This is a variable that will exist as part of the HTML page. It exists outside of AngularJS and independent of DOJO. This object will wrap the DOJO map object, however it is undefined in its default state.
Next, create an AngularJS factory to wrap the mapObjectWrapper:
2 return mapObjectWrapper;
3})
This factory allows us to use the mapObjectWrapper inside of an Angular controller without breaking the dependency injection principle of AngularJS that allows for easy module testing.
Now create the directive:
2 return {
3 restrict: 'EA',
4 controller: 'MapController',
5 link: function (scope, element, attrs, ctrl) {
6 ctrl.init(element);
7 }
8 };
9});
The restrict property of the directive allows the directive to be used as an entity, or as an attribute. In the code we shared in the previous section it was used as an Entity, like this:
2</esri-map>
However, the could also have been used as an attribute, like this:
2</div>
This directive specifies both a controller and a link function. The controller refers to MapController, something we haven't created yet. The link function does not contain any code other than to execute an init() method on the controller. This approach is used so that the controller is separate from the directive and can be tested independently.
The real meat of this directive is in the controller, so let's create that next:
2 ['$rootScope', '$scope', '$attrs','esriMapService',
3 function ($rootScope, $scope, $attrs, esriMapService) {
4 $scope.mapService = esriMapService;
5 $scope.mapService.scope = $scope;
6}
The previous code block creates a controller on the esriMap module. The controller is named MapController. There are four services passed into it:
- $rootScope: The $rootScope service will be used for broadcasting evnts to other aspects of the application.
- $scope: The angular $scope service is used for sharing data between a view and controller.
- $attrs: The $attrs service will contain all the attributes on our custom HTML element. It will allow us to introspect the attributes if need be.
- esriMapService: The esriMapService is the custom service we created, which wraps the mapObjectWrapper.The mapService is saved to the local $scope, and the local $scope is saved as a property on the mapService object. This is so that our DOJO code can execute a function on the the directive’s $scope once it is loaded. You'll see this code later.
Here is the init() function:
2 if (!$attrs.id) { throw new Error('\'id\' is required for a map.'); }
3 $scope.$element = element;
4 if(!$scope.mapService.map){
5 return;
6 }
7 $scope.recreateMap();
8};
The init() function is called from the link in the directive's controller. The first piece of code in the the method is to make sure that an id attribute exists on the directive's tag. The ID is required by the ESRI map component. The element argument represents the tag that created the directive. The element is stored into the controller’s $scope for later reference.
If the mapService.map variable is not yet defined, then the method’s execution stops. Otherwise, a recreateMap() method is called in the controller:
2 createDiv();
3 createMap();
4}
This method just calls two other methods. The createDiv() method is used to create a separate div required by the mapping component. The createMap() component will create a map on that div. First, create a variable to contain the map div:
Now, we can examine the method:
2 if(mapDiv){
3 return;
4 }
5 mapDiv = document.createElement('div');
6 mapDiv.setAttribute('id', $attrs.id);
7 $scope.$element.removeAttr('id');
8 $scope.$element.append(mapDiv);
9};
If the mapDiv already exists, then the function processing is terminated. If the mapDiv doesn't exist, then a new div is created. It is given the same ID that is specified as an attribute. The id attribute is removed from the main tag. This is so that the map component does not get confused by two separate divs identically named. Finally the new map div is added to the DOM as a child of the main element.
Here is the createMap function:
2 if(!$scope.mapService.map){
3 return;
4 }
5 if(!mapDiv){
6 return;
7 }
8 var options = {
9 center: $attrs.center ? JSON.parse($attrs.center) : [-56.049, 38.485],
10 zoom: $attrs.zoom ? parseInt($attrs.zoom) : 10,
11 basemap: $attrs.basemap ? $attrs.basemap : 'streets'
12 };
13 $scope.map = new $scope.mapService.map($attrs.id, options);
14 $scope.map.on('load', function () { $rootScope.$broadcast('map-load'); });
15 $scope.map.on('click', function (e) { $rootScope.$broadcast('map-click', e); });
16};
First, the method checks to see if the mapService is loaded and if the mapDiv is created. If either of these conditions is false, then the code is not executed.
Next the code creates an options object. This options object properties are created by introspecting the attributes on the directive's main tag, and uses those attributes to create a options object to send to the map. In this case, only three are implemented: center, zoom, and basemap.
I'm unsure how I feel about the code examining the attributes to create the options object. I’d rather pass this responsibility back to the directive user, so they can add or remove properties as needed. The current approach will need to change the directive code if we want to add, or remove, option parameters. In my Angular travels it is more common to see an options object passed into the directive, and that options object contains all the relevant parameters. I’d much prefer that to introspecting the attributes however I did not have time to change that for this proof of principle demo.
The next line of the method uses the DOJO mapService’s map() method to create the map on the screen. The last two lines add load and click event handlers on the map. This uses the $rootScope to broadcast events, essentially telling anyone listening that these actions occurred on the map.
The final piece of code is the DOJO statement to load ESRI’s map function. This should exist outside of any Angular code:
2 mapObjectWrapper.map = Map;
3 mapObjectWrapper.scope.recreateMap();
4});
The DOJO component is loaded using the require() method. That means it is not loaded immediately, but queued up to be loaded as it is needed. Once loaded, this method accesses the mapObjectWrapper directly. It saves the map as a parameter inside the object, and then executes the recreateMap() function on the controller's scope. Remember when we added the scope to the esriMapService? This is why. It provides a hook so that the DOJO code can communicate with the Angular directive.
Final Thoughts
You can view a working demo here. The client I built this prototype for also put the code in their GitHub account. My own fork is here.
#1 by Namrata Patel on 5/12/15 - 11:07 AM
While trying to use your source code, I am getting error "Uncaught ReferenceError: angular is not defined" - Error is coming from below line in index.html
var mapTest = angular.module('mapTest',['map']);
Any help is greatly appreciated.
#2 by Namrata Patel on 5/12/15 - 11:15 AM
"Uncaught ReferenceError: require is not defined". It is coming from local.js line 19: require(['esri/map'], function (Map)
Any help is greatly appreciated.
#3 by Namrata Patel on 5/12/15 - 11:53 AM