Leafletjs maps for Umbraco 7

I’ve been working on a mapping property editor for Umbraco 7 recently that allows users to pick a location and zoom, or do an address lookup. Since users of the front-end website will see open street map tiles when they publish their pages, I thought it’d make sense to also show them the same style map in the back-office when they’re actually creating it. I learnt a few bits and pieces about angularjs directives here as well. Grab the code here if you like and read on.

Package.manifest

Here we reference the source leaflet.js script, the html partial view that renders the map, as well as the resource, directive and controller scripts. The resource is used to do address look-ups using open street map’s nominatim service (http://nominatim.openstreetmap.org). The directive is to render and control the map and the controller is to tie it all together.

Directive

These are like modules that can be used to decorate standard html tags, or can be used to define new ones that are rendered when angular ‘compiles‘ the markup. So, to create a map object once the directive has been set up, its just…

<lccmap width="100%" height="400px" defaults="model.value.mapDefaults"></lccmap>

To set up the directive, I created the file lccmaps.directive.js. Here’s an explanation of some bits of it…

angular.module("umbraco.directives")
.directive('lccmap', function ($timeout) {   // 1
 var map, layer, marker;
 return {
  restrict: 'E', // 2
  scope: {
   defaults: '=' // 3
  },
  replace: true, // 4
  template: '<div id="map" class="map"></div>', // 5
//...
  1. The directive requires access to the angular $timeout service which is used to resolve updates that the directive initiates that need to be shown in the view.
  2. The directive is restricted to elements with the tag ‘lccmap.’ So the directive can’t be defined as a tag attribute.
  3. The scope defines the level of access that the directive has. By setting the value as in ‘3’, this means that the directive gets access to whatever value the attribute of ‘defaults’ was set to in the view. Inside the directive, its also called ‘defaults’ (that’s what the ‘=’ bit does)
  4. When set to replace, the contents of the directive template will replace the markup in the view rather than append to it (which is what we want)
  5. The template can reference a separate file but for my purposes, it’s only outputting a single div, so this can be defined inline

Loading the map

Inside the ‘link’ function of the directive, we have the following…

$timeout(function () {
  map = getMap();
  initMap();
}, 1000);

For some reason (and please tell me if there is a better way of doing this), in Umbraco, if the call to actually load the map tiles is not wrapped in a timeout function, it doesn’t load properly. It sort of loads but then you need to resize the browser window to force a repaint of the map. A bit crap but there you go.

The ‘getMap’ function actually loads the tile layers and creates the default marker based on the defaults that were set when the property editor loaded (the defaults are set in the controller).

The ‘initMap’ function sets up a load of watches for when the defaults are changed, when the map is zoomed or when the marker is dragged.

scope.$watch('defaults', function (nv, ov) {
  map.setView(new L.LatLng(nv.lat, nv.lng), nv.zoom);
  marker.setLatLng(new L.LatLng(nv.lat, nv.lng));
}, true);  // 1

map.on('zoomend', function (e) {
  $timeout(function () {
    scope.defaults.zoom = e.target._zoom;
  }, 50);
}, true); // 2

marker.on('dragend', function (e) {
  $timeout(function () {
    scope.defaults.lat = e.target._latlng.lat;
    scope.defaults.lng = e.target._latlng.lng;
    map.panTo(e.target._latlng);
  }, 50);
}); // 3
  1. All defaults of the map are watched (by default). If they do change, the centre of the map is updated using the new lat/lng values. By setting the third parameter of $watch to ‘true,’ this allows watching of the variable as well as all its child objects. For the purposes of the map, this isn’t a bad thing as its not a very big object.
  2. If the map ‘zoomend’ event is fired (either by clicking the zoom buttons or doing a mouse wheel scroll), the default zoom of the map is updated
  3. If the marker is dragged, the default lat/lng values are updated based on the markers position. Then the map pans to those coordinates. This means that if an address look-up is done, the actually pointer can be finely adjusted to get it just right

Address look-up

As I mentioned, this is done using openstreetmap’s nominatim REST API which is pretty good. It allows lots of different ways of searching for addresses. There is a usage limit of 1 request per second but for a back-office property editor, we should be ok with this.

Markup

<input type="text" placeholder="e.g. leicester or le1 or braunstone" title="find an address" id="address-search" ng-model="model.value.addressSearch" />
  <a class="btn btn-primary" id="address-search-submit" ng-click="addressLookup()">Search</a>

From the view, its a simple textbox and button. When keywords are entered and submit is pressed, the ‘addressLookup’ function is called.

Controller

$scope.addressLookup = function () {
lccMapAddressFindResource.getAddresses($scope.model.value.addressSearch)
.success(function (data, status, headers, config) {
  $scope.model.value.addresses = data;
})
.error(function (data, status, headers, config) {
  notificationsService.error("An error occurred: " + status);
});
}

The controller calls the resource that actually does the look-up, using the search term. If it was successful, the view loops through the addresses and outputs them. If not, a notification is shown.

Resource

This just has one function ‘getAddresses’ which looks up the nominatim service and returns a promise.

angular.module("umbraco.resources")
.factory("lccMapAddressFindResource", function ($http) {
  var addressService = {},
  addressLookupUrl = "http://nominatim.openstreetmap.org/search/",
  searchConfig = "?format=json&addressdetails=0&limit=5&polygon_svg=0";
  addressService.getAddresses = function (q) {
    return $http.get(addressLookupUrl + encodeURIComponent(q) + searchConfig)
    }
    return addressService;
})

There are some defaults set here to limit the number of addresses returned and in what format the data should be returned.

More markup

To actually output the addresses I did this…

<ul>
 <li ng-repeat="address in model.value.addresses">
  <a ng-click="selectAddress(address)"><span ng-bind="address.display_name" style="cursor:pointer; text-decoration: none;"></span></a>
 </li>
</ul>

This loops through each address and adds a click event. If the address is clicked, the map default lat and lng are set to the address lat and lng. Since the directive is watching the defaults object, the map will update its center point and move the marker to it.

Conclusion

That’s it, a nice open-source mapping solution. Obviously lots more could be done here but this covers all the bases for what I need (for now). Comments always welcome (especially that thing about having to use $timeout all the time).

 

Leave a Reply