Slippy Maps in Blazor with Leaflet

Note: Updated November 2020 for .NET 5 and a good, hard refactoring. See the JavaScript References in .NET 5 section below for the bulk of the changes.

Wouldn’t it be nice to have interactive slippy maps in a Blazor application? Yes, it would!

Wouldn’t it be even nicer if the map component completely encapsulated all of the JavaScript interop and the page it was on was blissfully unaware that JavaScript even existed? Yes, yes, yes!

The hard work has already been by Leaflet.js. All that remains is to write a simple Blazor component to talk to the Leaflet API. (You can download my Darnton.Blazor.Leaflet NuGet package.)

The Demo Solution

The demo solution (available on GitHub: LeafletBlazor) has two projects. The Darnton.Blazor.Leaflet project contains the LeafletMap component, all the domain classes, and the JavaScript interop magic. This is what ends up in the NuGet package. The second is a Blazor WebAssembly project, which contains a page hosting the Map component.

A Simple Test Rig

First, include the Leaflet CSS and JavaScript on the host project’s index page. This is all the client project needs to know about the underlying implementation.

<head>
    ...
    <link href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" rel="stylesheet"
          integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
          crossorigin="" />
    <link href="css/leaflet-map.css" rel="stylesheet" />
</head>

<body>
    ...
    <script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"
            integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
            crossorigin=""></script>
</body>

The LeafletMap Razor component will take a Map instance and a TileLayer. The Map class is central to everything and the TileLayer gives us the first layer of images.

@page "/"
@inherits IndexBase

<h1>Leaflet Map Test</h1>

<div class="my-3">
    <LeafletMap Map="PositionMap" TileLayer="OpenStreetMapsTileLayer" />
</div>

There are lots of options for tiles – I’ll use the freely available OpenStreetMap tiles in this example. These aren’t intended for production use, so please play nice.

In my test rig code, these are hard-coded.Something more sophisticated is left as an exercise for the reader.

    public class IndexBase : ComponentBase, IAsyncDisposable
    {
        protected Map PositionMap;
        protected TileLayer OpenStreetMapsTileLayer;

        public IndexBase() : base()
        {
            PositionMap = new Map("testMap", new MapOptions
            {
                Center = new LatLng(-42, 175),
                Zoom = 4
            });
            OpenStreetMapsTileLayer = new TileLayer(
                "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
                new TileLayerOptions
                {
                    Attribution = @"Map data © <a href=""https://www.openstreetmap.org/"">OpenStreetMap</a> contributors, " +
                        @"<a href=""https://creativecommons.org/licenses/by-sa/2.0/"">CC-BY-SA</a>"
                }
            );
        }

        public async ValueTask DisposeAsync()
        {
            await OpenStreetMapsTileLayer.DisposeAsync();
            await PositionMap.DisposeAsync();
        }
    }

That’s it for the test rig. Note that there’s no mention of JavaScript interop in the client component. It doesn’t even have an IJSRuntime injected into it. I’ve just created the C# Map and TileLayer objects and passed them to the LeafletMap component.

Wrap the Map

The Razor component is dead simple. It’s a just a div for the Leaflet map to be rendered in.

@inherits LeafletMapBase

<div class="leafletMap" id="@Map.ElementId"></div>

The code behind has Map property and a TileLayer property, passed in from the client code above, which is just enough to get something on the screen. The Map has an ElementId that links the HTML element to the JavaScript object and options that set the centre of the map and the zoom level. The TileLayer has information about where to find tile images.

Once the div that will hold the map is ready, the component uses the OnAfterRenderAsync method to bind the Map and TileLayer objects to their JavaScript counterparts.

public class LeafletMapBase : ComponentBase
{
    [Inject] public IJSRuntime JSRuntime { get; set; }
    [Parameter] public Map Map { get; set; }
    [Parameter] public TileLayer TileLayer { get; set; }

    protected async override Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            await Map.BindJsObjectReference(JSRuntime);
            await TileLayer.AddTo(Map);
        }
    }
}

That’s just enough glue to hold the Blazor component and the Leaflet objects together. How the C# Leaflet objects are bound to the JavaScript implementation is next.

JavaScript References in .NET 5

The binding of the C# object to the JavaScript equivalent has got much simpler in .NET 5. Until now, storing references to JavaScript objects in .NET wasn’t easy. In my post on using Babylon with Blazor I explained how I’d knocked up a DIY object store and reviver function. In .NET 5, it’s all built in, so I can chuck away a bunch of plumbing.

In line with Larry Wall’s three virtues, I’ve gone to great effort to make getting a simple map onto the page as easy as possible. The LeafletMap Razor component takes its Map and binds it to a JavaScript object reference. Then it adds the TileLayer to the Map, as you see in the code above.

Here’s what goes on behind the scenes:

An abstract InteropObject class handles the creation and disposal of the JavaScript object reference. It has a binder to handle the JavaScript interop and a reference to the JavaScript object. It has an abstract factory method that each derived type uses to create its JavaScript counterpart. None of this is exposed to the outside world

public abstract class InteropObject : IAsyncDisposable
{
    internal LeafletMapJSBinder JSBinder;
    internal IJSObjectReference JSObjectReference;

    internal async Task BindJsObjectReference(LeafletMapJSBinder jsBinder)
    {
        JSBinder = jsBinder;
        JSObjectReference = await CreateJsObjectRef();
    }

    protected abstract Task<IJSObjectReference> CreateJsObjectRef();

    public async ValueTask DisposeAsync()
    {
        await JSObjectReference.DisposeAsync();
    }
}

This is how the Map class does it. The implementation of the create function uses the binder to invoke Leaflet’s L.map() function and returns the object reference.

    protected override async Task<IJSObjectReference> CreateJsObjectRef()
    {
        return await JSBinder.JSRuntime.InvokeAsync<IJSObjectReference>("L.map", ElementId, Options);
    }

IJSObjectReference is the secret sauce and the reason I could delete so much confusing stuff from the older version. Using IJSObjectReference as your result type triggers special handling in the interop layer. The short version of the story is that I can directly call the Leaflet JavaScript, get a reference to a Leaflet object back, and then call functions on that object. All the plumbing is now written by Microsoft and not bodged together by me.

So, I’ve got a map but I still can’t see anything. I need a Tile Layer to provide the imagery.

The TileLayer’s factory method is very similar to the Map’s. It calls L.tileLayer() with an image source and some options.

    protected override async Task<IJSObjectReference> CreateJsObjectRef()
    {
        return await JSBinder.JSRuntime.InvokeAsync<IJSObjectReference>("L.tileLayer", UrlTemplate, Options);
    }

I don’t bind the TileLayer explicitly, like I did with the Map. Instead, I leave it as late as possible – the way I do all my programming.

The Map already has the binding information, so I can wait until I add the TileLayer to the Map and create the reference at that point. When I’ve got the TileLayer, I can add it to the map with the layer.addTo() function. This requires a bit of trickery that I’ll explain below.

    public async Task<Layer> AddTo(Map map)
    {
        if (JSBinder is null)
        {
            await BindJsObjectReference(map.JSBinder);
        }
        GuardAgainstNullBinding("Cannot add layer to map. No JavaScript binding has been set up.");
        var module = await JSBinder.GetLeafletMapModule();
        await module.InvokeVoidAsync("LeafletMap.Layer.addTo", this.JSObjectReference, map.JSObjectReference);
        return this;
    }

My original plan was to write a one-liner to invoke the JavaScript addTo() function on the object reference and pass it the Map’s object reference.

    // Does not work!
    await JSObjectReference.InvokeVoidAsync("addTo", map.JSObjectReference);

This doesn’t work because Leaflet’s layer.addTo() function returns the Layer object, to allow function chaining. The layer object contains circular references, via the Map back to itself. Circular references can’t be serialised by JSON.stringify() and so the code above causes a JavaScript error. (Despite using JSObjectReference.InvokeVoidAsync the serialisation is still attempted because InvokeVoidAsync delegates directly to InvokeAsync.)

To get round this, I created a JavaScript shim that calls layer.addTo() but returns nothing, thus dodging the circular reference serialisation problem. The whole thing’s exported as a module for the component to use.

export let LeafletMap = {
    Layer: {
        addTo: function (layer, map) {
            layer.addTo(map);
        },
        remove: function (layer) {
            layer.remove();
        }
    }
}

This is the reason for having the JSBinder class instead of using JSRuntime directly. The JS binder imports the module (as described in this JavaScript isolation post) and the interop functions can use the JS runtime directly or the module reference as required.

    internal class LeafletMapJSBinder : IAsyncDisposable
    {
        internal IJSRuntime JSRuntime;
        private Task<IJSObjectReference> _leafletMapModule;

        public LeafletMapJSBinder(IJSRuntime jsRuntime)
        {
            JSRuntime = jsRuntime;
        }

        internal async Task<IJSObjectReference> GetLeafletMapModule()
        {
            return await (_leafletMapModule ??= JSRuntime.InvokeAsync<IJSObjectReference>("import", "./_content/Darnton.Blazor.Leaflet/js/leaflet-map.js").AsTask());
        }

        public async ValueTask DisposeAsync()
        {
            if (_leafletMapModule != null)
            {
                var mapModule = await _leafletMapModule;
                await mapModule.DisposeAsync();
            }
        }
    }

In the Layer class, the direct call to the Leaflet function is replaced with a call to the shim layer. The C# function still returns the Layer to allow method chaining but nothing is returned back across the interop boundary.

Creating JavaScript objects and holding on to their references, invoking calls on those objects, and passing back object references. That’s all there is to it – with the addition of a little shim to prevent an annoying error. Do the same for any markers, lines, or other layers that you want to add to the map.

Get the Code

The .NET 5 version of this is much simpler than the .NET Core 3.x version was – a lot of plumbing has been removed.

If you’re starting with .NET 5, it’s easy. If you’re starting with an earlier version, you’ll need to tinker with your Blazor WebAssembly project file and NuGet package references. See the relevant sections on this migration page.

All the code for this library is on GitHub: Darnton / LeafletBlazor. (Check the dotnet5 branch if it hasn’t been merged to master yet.) The package is on NuGet: Darnton.Blazor.Leaflet. There’s a lot in the Leaflet API and I’ve only implemented the basics, but it’s enough to see what’s going on and it’s easy to extend.

Happy mapping! And happy JavaScript object referencing!

One thought on “Slippy Maps in Blazor with Leaflet

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s