Search code examples
angularjsleafletangular-leaflet-directive

AngularJS + Leaflet – Initialise a leaflet map on a service


I'm building an app in AngularJS that uses LeafletJS for interacting with a map, offering different possible interactions separated across what I call phases. For each of those phases there is a UIRouter state, with its controller and template.

I'm currently providing the leaflet functionality through a service. The idea was for that service to initialise a Leaflet map and provide some limited access to the state's controller. Those controllers would thus call service functions such as setupMarkersInteractions to setup callbacks that enable marker placement on the map, for example.

However, I'm running into a problem when initialising the map through Leaflet's leaflet.map() function, namely: Error: Map container not found. This is related to Leaflet's inability to find the HTML element with which the map should be associated.

Currently, I'm kinda doing this:

function mapService() { 
  var map;

  return {
    initializeMap : initializeMap,
    setupMarkersInteractions : setupMarkersInteractions
  };

  function initializeMap() {
    map = leaflet.map('map');
  }

  function setupMarkersInteractions() {
    map.on('click', markerPlacementCallback);
  }
}

The initializeMap function tells leaflet.map to look for a HTML element with id='map', which is declared on the state's template.

Now, for the actual question, is this related to some kind of AngularJS services' inability to access the HTML template? I couldn't find anything on the matter, but I thought that it would make sense for services to not directly access the view...
If it is, what kind of workaround should I explore? I've looked into leaflet-directive, but it doesn't seem to offer the possibility to add and remove custom callbacks with the flexibility I would like to (things get complex when I add free draw functionality with Leaflet-Freedraw, for example).

I considered using leaflet.map directly with an HTMLElement argument for the element but still I couldn't make it work - although there is a probability that I'm not passing what is supposed to.


Solution

  • What's happening is that at the moment L.Map tries to access the DOM from your service, the template is available yet. Normally services get loaded and injected into controllers, controllers initialize their scopes, after that the templates get initialized and added to DOM. You'll see if you'll put a large timeout on your map initialization that it will find it's DOM element. But that's a very ugly hack. In Angular you should use a directive to add logic to DOM elements.

    For example, a template: <leaflet></leaflet> and it's very basic directive:

    angular.module('app').directive('leaflet', [
      function () {
        return {
          replace: true,
          template: '<div></div>',
          link: function (scope, element, attributes) {
            L.map(element[0]);
          }
        };
      }
    ]);
    

    You can hook that up to your service and pass the element to your initialization method:

    angular.module('app').directive('leaflet', [
               'mapService'
      function (mapService) {
        return {
          replace: true,
          template: '<div></div>',
          link: function (scope, element, attributes) {
            mapService.initializeMap(element[0]);
          }
        };
      }
    ]);
    

    That way the initializeMap method will only be called once the actual DOM element is available. But it presents you with another problem. At the moment your controller(s) are initialized, your service is not ready yet. You can solve this by using a promise:

    angular.module('app').factory('leaflet', [
                 '$q',
        function ($q) {
            var deferred = $q.defer();
            return {
              map: deferred.promise,
              resolve: function (element) {
                deferred.resolve(new L.Map(element));
              }
            }
        }
    ]);
    
    angular.module('app').directive('leaflet', [
               'leaflet',
      function (leaflet) {
        return {
          replace: true,
          template: '<div></div>',
          link: function (scope, element, attributes) {
            leaflet.resolve(element[0]);
          }
        };
      }
    ]);
    

    If you want to use the map instance in your controller you can now wait untill it's resolved:

    angular.module('app').controller('rootController', [
               '$scope', 'leaflet',
      function ($scope,   leaflet) {
        leaflet.map.then(function (map) {
          var tileLayer = L.tileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
            maxZoom: 18
          }).addTo(map);
          map.setView([0, 0], 1);
          L.marker([0, 0]).addTo(map);
        });
      }
    ]);
    

    Here's an example of the concept on Plunker: http://plnkr.co/edit/DoJpGqtR7TWmKAeBZiYJ?p=preview