Using Leaflet maps in an Elm app

Posted on Wed 30 August 2017 in programming

I recently started a project creating a web app that requires displaying maps. This was to be a browser based app, but I wanted to use something safer and more maintainable than Javascript. I decided to give Elm a try and have been fairly happy with it. However, I couldn't find any pure Elm libraries for displaying maps that supported WMS layers and other features that would be needed.

So, the question became whether it would be possible to use a Javascript mapping library with my Elm application. It turns out that it is not too difficult to get basic rendering of maps to work using the Leaflet library. That will be the subject of this post.

In the future, I will attempt to add more advanced features such as selecting regions within a displayed map.

Paradigm conflict

The main difficulty with using Elm and Leaflet together is that Elm is totally based on the reactive paradigm where the entire state of your app is captured in a single immutable data structure from which the DOM is derived via a pure view function. Any change in what the user sees has to be accomplished by updating the state (Model in Elm parlance) in some way and allowing the DOM to be recomputed. This way of doing things and the resulting program structure are called The Elm Architecture, or TEA.

Behind the scenes the view function actually generates virtual DOM structures which are diffed to compute mutations to the real DOM. This is so that minor changes don't cause the whole page to be redrawn. However, this mechanism is not visible to the application programmer.

On the other hand, Leaflet and similar Javascript map libraries make use of mutation and side effects with full abandon. The DOM is treated as a persistent mutable object within which div elements may have a map objects attached. The map object then provides methods such as setView or addLayer that mutate the state of the map and update the DOM to effect the change.

Elm interop with Javascript

The primary means of interoperating with external Javascript in Elm is through ports. These allow incoming messages to be subscribed to, and outgoing messages to be dispatched. Incoming messages result in calls to a model update function when received. Outgoing messages can be generated upon model updates.

My initial approach was to use this port system to send messages out to Javascript for actions like: add a map to the div with id foobar, remove the map from the div, add this WMS layer. I managed to get this scheme to work, but it was complicated and seemed contrary to TEA. I was essentially maintaining program state in two separate locations, the Elm model and the Leaflet interface, and shuttling messages back and forth to keep them in sync.

What I really wanted was something like the way regular HTML is rendered in Elm, where the map could just be computed purely from the model.

Enter mutation observers

Ideally, it would be possible to tie-in to or extend the Elm virtual DOM system. There is an undocumented Elm feature called Native Modules that might make that possible, but I haven't investigated that very much.

Instead, I have currently settled on a solution that involves adding a HTML5 data attribute with the map state to the to-be Leaflet container divs. These divs are also given the class leaflet-map.

view : List WMSInfo -> Html msg
view wmsInfo =
    div
        [ class "leaflet-map"
        , attribute "data-leaflet" (serialize wmsInfo)
        ]
        []

Currently, the only map state I care about is a list of WMS layers to add to the map. The serialize function just makes JSON out of the layer information.

import Json.Encode exposing (..)


type alias WMSInfo =
    { mapName : String
    , layers : List String
    , endPoint : String
    }


serialize : List WMSInfo -> String
serialize =
    List.map
        (\info ->
            object
                [ ( "mapName", string info.mapName )
                , ( "endPoint", string info.endPoint )
                , ( "layers", info.layers |> List.map string |> list )
                ]
        )
        >> list
        >> encode 0

Now, on the Javascript side I can watch for mutations to the DOM involving elements with the leaflet-map class and invoke the necessary Leaflet methods to match the state in the data attribute. The relevant DOM mutations are when elements with the leaf-map class are added or removed anywhere in the page body and when the data-leaflet attribute changes on any element.

There is a Web API for doing such things called MutationObserver. The way it is used is to create an observer instance with a callback function that receives mutation events, and then activate the observer with some filters about what kinds of mutations to look for.

var observer = new MutationObserver(processMutations);

observer.observe(document.body, {
    subtree: true,
    childList: true,
    attributes: true,
    attributeFilter: ["data-leaflet"]
});

The subtree flag means to watch for mutations anywhere in the descendants of the observed element, which is the entire page body in this case. The childList flag will catch additions or removals of elements. The attributes flag indicates that changes to element attributes are of interest, but the attributeFilter allows the parameter allows that observation to be limited to only the data-leaflet attribute that contains the map information.

Mutating the map

Using this mechanism to observe relevant mutations to the DOM, the corresponding calls to the Leaflet library can be made to make it match the desired state.

function processMutations(mutations) {
    mutations.forEach(function(m) {
        processAddedNodes(m.addedNodes);
        processRemovedNodes(m.removedNodes);

        if (m.type == "attributes") {
            updateMap(m.target);
        }
    });
}

The mutation observer invokes its callback with a list of mutations. Each mutation object has a list added and a list of removed child nodes, either of which may be empty if there was nothing added or removed. There is also a type attribute that indicates the type of mutation. Since there is only one attribute that matches the attribute filter, any mutation of the type "attributes" means the element that was mutated, which is m.target, is a map container div that needs its map to be updated.

Taking added nodes first, each added node is examined for any elements with the class leaflet-map, because the added node may contain an entire subtree with multiple maps. For each matching element, the Leaflet map constructor is invoked. Leaflet set an undocumented attribute, _leaflet_id, on the container element. This unique id is used as the key in a global dictionary, called maps, where the Leaflet map object will be stored for later access. Finally, updateMap is called that to handle setting the map state, which will be explained subsequently.

function processAddedNodes(addedNodes) {
  addedNodes.forEach(function(n) {
      if (n.getElementsByClassName == null) return;

      var elements = n.getElementsByClassName("leaflet-map");
      Array.prototype.forEach.call(elements, function(element) {
          var map = L.map(element, {crs: L.CRS.EPSG4326}).setView([0, 0], 1);
          maps[element._leaflet_id] = map;  
          console.log("added leaflet id", element._leaflet_id);
          updateMap(element);
      });
  });
}

If a map is added or the data-leaflet attribute is changed, the map needs to be updated with current state. The Leaflet map object instance is obtained from the global maps dictionary according to the _leaflet_id attribute mentioned above. A separate global mapLayers dictionary keeps track of the layers that have been added to the map. To accomplish the update, all existing layers are first removed. Then, the JSON data that was stored in data-leaflet attribute needs to obtained. It will be contained in the HTMLElement dataset property, element.dataset.leaflet. This is the JSON data serialized from the Elm application. It is parsed and used to add layers to map. Each created layer object is added to the mapLayers global so they can be accessed later.

function updateMap(element) {
    var map = maps[element._leaflet_id];
    if (map == null) return;
    console.log("updating leaflet id", element._leaflet_id);

    var layers = mapLayers[element._leaflet_id];
    if (layers != null) {
        layers.forEach(function(layer) {  map.removeLayer(layer); });
    }

    var wmsInfos = JSON.parse(element.dataset.leaflet);

    mapLayers[element._leaflet_id] = wmsInfos.map(function(wmsInfo) {
        return L.tileLayer.wms(wmsInfo.endPoint, {
            mapName: wmsInfo.mapName,
            format: 'image/png',
            version: '1.1.0',
            transparent: true,
            layers: wmsInfo.layers.join(',')
        }).addTo(map);
    });
}

When nodes are removed, the node's descendants are again searched for the leaflet-map class. Each such element is inspected for the _leaflet_id attribute described above. If the _leaflet_id attribute is present, the processAddedNodes function must have added the map. This means the _leaflet_id value will be in the global maps dictionary and can be used to obtain the corresponding Leaflet map instance, allowing its remove() method to be invoked. The corresponding maps and mapLayers entries are then nulled out.

function processRemovedNodes(removedNodes) {
    removedNodes.forEach(function(n) {
        if (n.getElementsByClassName == null) return;

        var elements = n.getElementsByClassName("leaflet-map");
        Array.prototype.forEach.call(elements, function(element) {
            if (element._leaflet_id != null) {
                console.log("removing map with leaflet id", element._leaflet_id);
                maps[element._leaflet_id].remove();
                maps[element._leaflet_id] = null;
                mapLayers[element._leaflet_id] = null;
            }
        });
    });
}

Final thoughts

The method described in this article seems to work fairly well, and it is what I am currently using in my project. I can't say it is entirely satisfactory, because there is quite a bit of incidental complexity, all of which is on the unsafe Javascript side of the interface.

I will undoubtedly be revisiting this code. There other features that are needed including selecting regions and synchronizing the view between multiple maps.