Distinguish Between Manual and Programmatic Map Movement in Leaflet

June 26th 2016 Leaflet AngularJS

Leaflet map state can be manipulated in two ways: manually using mouse to pan and zoom or programmatically with a set of methods for this specific purpose. In some cases it can be useful to know which of these two approaches was used for manipulating the map; e.g. if the map is automatically following a moving object, you might want to disable this feature when a user moves the map manually. Unfortunately Leaflet doesn't have a built-in way to distinguish between the two map manipulation modes, but with a good enough understanding of the JavaScript runtime this can still be achieved.

Let's create a simple AngularJS application to try it out. The page will display a map with a couple of markers:

<!DOCTYPE html>
<html ng-app="demoapp">

  <head>
    <link rel="stylesheet"
          href="//cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/leaflet.css" />
    <script src="//cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/leaflet.js"></script>
    <script src="https://code.angularjs.org/1.5.7/angular.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/angular-leaflet-directive/0.10.0/angular-leaflet-directive.min.js"></script>
    <link rel="stylesheet" href="style.css" />
    <script src="script.js"></script>
  </head>

  <body ng-controller="MapController">
    <leaflet lf-center="center" markers="markers" width="100%" height="480px">
    </leaflet>
    <button ng-click="autoMode = !autoMode">
      {{autoMode ? 'Auto' : 'Manual'}} mode
    </button>
  </body>

</html>

To make Leaflet more AngularJS friendly, I am using a Leaflet directive as a wrapper for it. The markers and the starting center point for the map are defined in the controller:

var app = angular.module('demoapp', ['leaflet-directive']);
app.controller('MapController', ['$scope', function($scope) {
  $scope.center = {
    lat: 46.43,
    lng: 14.06,
    zoom: 13
  };
  $scope.markers = {
    jesenice: {
      lat: 46.4367,
      lng: 14.0526
    },
    hrusica: {
      lat: 46.4489,
      lng: 14.0113
    },
    koroskaBela: {
      lat: 46.4262,
      lng: 14.0988
    },
    blejskaDobrava: {
      lat: 46.4077,
      lng: 14.0989
    }
  };
}]);

As the source of the programmatic map movement, we will center the map on a marker when a user clicks on it. Additional couple of lines in the controller will do the trick:

$scope.$on('leafletDirectiveMarker.click', function(event, args) {
  var marker = $scope.markers[args.modelName];
  $scope.center.lat = marker.lat;
  $scope.center.lng = marker.lng;
});

Now we will want to disable this behavior when the user manually pans or zooms the map. Let's start by introducing two distinct modes for the application: automatic and manual. Clicking a marker will only move the map in automatic mode.

First, we'll expand the controller:

$scope.autoMode = true;
$scope.$on('leafletDirectiveMarker.click', function(event, args) {
  if ($scope.autoMode) {
    var marker = $scope.markers[args.modelName];
    $scope.center.lat = marker.lat;
    $scope.center.lng = marker.lng;
  }
});

We'll also add a button to the page to display the current mode and to toggle between the two modes:

<button ng-click="autoMode = !autoMode">{{autoMode ? 'Auto' : 'Manual'}} mode</button>

Now, let's switch to manual mode, whenever the user manipulates the map. There are a couple of related events available. The most suitable one for our purpose will be movestart which is triggered as soon as the user starts dragging or zooming the map.

$scope.$on('leafletDirectiveMap.movestart', function(event, args) {
  $scope.autoMode = false;
});

This will switch to manual mode on every user interaction, but unfortunately also when the map moves programmatically because a marker was clicked. Since the event doesn't include any information, which could be used to determine the cause of the movement, we will try a different approach: we will make sure a special flag is set whenever the map is manipulated programmatically by setting it just before and resetting it immediately after the manipulation. When handling the movestart event, we will then be able to check the state of the flag:

var programmaticMapManipulation = false;
$scope.$on('leafletDirectiveMarker.click', function(event, args) {
  if ($scope.autoMode) {
    var marker = $scope.markers[args.modelName];
    programmaticMapManipulation = true;
    $scope.center.lat = marker.lat;
    $scope.center.lng = marker.lng;
    programmaticMapManipulation = false;
  }
});

$scope.$on('leafletDirectiveMap.movestart', function(event, args) {
  if (!programmaticMapManipulation) {
    $scope.autoMode = false;
  }
});

If you try this out, you'll notice it still doesn't work. Why? Because of AngularJS's approach to updating bindings. The changes made to $scope.center will only be propagated to Leaflet when $scope.digest() is called. This will happen automatically at the end of the event handler, i.e. after the flag is already reset. And no, AngularJS will not allow you to call $scope.$digest() before resetting the flag because this code is already running inside its digest loop and nesting is not supported.

How to fix this problem? We need to ensure the flag is reset as soon as possible, but only after AngularJS updates the bindings and calls the Leaflet methods to move the map. Thanks to Javascript's event loop this can be achieved by calling setTimeout() with zero delay. This will add a message to the queue that will be processed immediately after any existing messages there, i.e. after AngularJS is done with binding updates. Instead of calling setTimeout() directly, we'll use AngularJS's $timeout service as its wrapper:

var app = angular.module('demoapp', ['leaflet-directive']);
app.controller('MapController', ['$scope', '$timeout', function($scope, $timeout) {

  // other controller code

  $scope.$on('leafletDirectiveMarker.click', function(event, args) {
    if ($scope.autoMode) {
      var marker = $scope.markers[args.modelName];
      programmaticMapManipulation = true;
      $scope.center.lat = marker.lat;
      $scope.center.lng = marker.lng;
      $timeout(function() {
        programmaticMapManipulation = false;
      });
    }
  });
}]);

This is enough to fix the remaining issue: clicking on markers to move the map will not disable the automatic mode any more, but any dragging or zooming still will. You can test it yourself in the embedded Plunk:

Copyright
Creative Commons License