Leaflet maps in Django with model-backed geoJSON data

Python Django GIS Leaflet geojson

2 December, 2020 (updated 9 April, 2021)



Geographical data in the geoJSON format can be plotted onto lightweight Leaflet maps in Django pretty easily. If all you're doing is creating a simple map with a small amount of data, I think this is a great approach that avoids installing a ton of dependencies. (If you've ever tried to set up GeoDjango, especially on a Windows machine, you'll know the hell that this can be.)

Overview

This is roughly the order of operations:

  • Create a base Leaflet map, using free OpenStreetMap tiles;
  • Create a Django model to hold geographical data;
  • Convert the geographical data into geoJSON to plot on the map.
The whole thing is contained inside a Django app, and you can see the final product here. You can find the source code for the final product here.

Leaflet map skeleton

I'm assuming that you've set up a Django project with an app named "maps", and set up a simple view/URL/template for your map's page. My view will be index, so the corresponding template is index.html.

First and most importantly is to just get a map off the ground. Following their quick-start guide, we're going to add Leaflet's CSS and Javascript links to the header of our Django template. It will look something like this (check the guide for the most up-to-date code):

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

    <!-- Make sure you put this AFTER Leaflet's CSS -->
    <script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"
        integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
        crossorigin="">
    </script>
</header>

Next, we'll put a div element inside the HTML body, and make sure to add a height style:

<body>
    <div id="mapid" style='height: 500px'></div>
</body>

The last thing we need to do at this point is to actually create the map! We create a Javascript map object with an ID that corresponds to the ID of the div we defined above. We can then set some latitude and longitude coordinates, as well as a zoom level:

<script>
    var mymap = L.map('mapid').setView([-41.3, 174.75], 12);
</script>

Alright, if we check our page now, we'll see the skeleton of a Leaflet map with a height of 500 pixels! The next step is to actually add the tiles.

OpenStreetMap tiles

Our Leaflet map will be getting its tiles from Mapbox's Static Tiles API. These will make use of OpenStreetMap data.

Firstly, we need to get an API token from here. Store that in an environment variable (I've called mine MAPBOX_TOKEN). Then, open up your Django app's settings.py, and read it in. Something like this:

MAPBOX_TOKEN = os.getenv('MAPBOX_TOKEN')

Alright, now we need to pass that from the view to the template (for whichever page we're hosting the map on). My view will be index, because it's the home page for this "maps" app. My project is named config, so this is what the simple view will look like:

from django.shortcuts import render
from config.settings import MAPBOX_TOKEN

def index(request):
    context = {
        'MAPBOX_TOKEN': MAPBOX_TOKEN
    }
    return render(request, 'index.html', context)

Easy.

Alright, now we're pretty much going to copy and paste the Javascript example code in the Leaflet quick-start guide. The only thing we'll need to change is to replace the access token placeholder with our actual access token. Since this is Javascript, we'll need to escape it as well, using Django's templating syntax: '{{MAPBOX_TOKEN|escapejs}}'. Note the quotes, the double curly brackets, and the piped escape. The script will look like this, after placing the API token in two places:

<script>
    var mymap = L.map('mapid').setView([-41.3, 174.75], 12);
    L.tileLayer('https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={{MAPBOX_TOKEN|escapejs}}', {
        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>, Imagery © <a href="https://www.mapbox.com/">Mapbox</a>',
        maxZoom: 18,
        id: 'mapbox/streets-v11',
        tileSize: 512,
        zoomOffset: -1,
        accessToken: '{{MAPBOX_TOKEN|escapejs}}'
    }).addTo(mymap);
</script>

If we run the dev server and have a look at our map's page (after routing the URLs, etc.), we'll have a map! With tiles! At this point we're pretty much professional cartographers who can command a salary well in excess of $200,000 a year, but let's take it a step further and blow everyone's minds by adding things to the map. We're going to do that by creating a model for our location data, converting it into geoJSON and passing it to the map.

Location data in a Django model

Our location data is going to be a simple lat–lon specification, with a name. (You can add any other metadata you want in your model, of course.) In models.py, then, let's define Location:

class Location(models.Model):
    name = models.CharField(max_length=50)
    lon = models.FloatField()
    lat = models.FloatField()

The only other thing we need to define here is a class method that we'll call serialize. This is going to take the name, lon and lat fields, and construct a dictionary that will resemble geoJSON. If you add more metadata to your model, you'll need to figure out which fields to append to in the serializer. At a basic level, type is a "Feature", properties will hold the model instance's name, and the geometry key will be a nested dictionary containing the lon and lat points. The final Location model looks like this:

class Location(models.Model):
    name = models.CharField(max_length=50)
    lon = models.FloatField()
    lat = models.FloatField()
    
    def serialize(self):
        import json
        json_dict = {}
        json_dict['type'] = 'Feature'
        json_dict['properties'] = dict(name=self.name)
        json_dict['geometry'] = dict(type='Point', coordinates=list([self.lon,self.lat]))
        return(json.dumps(json_dict))

Don't forget to register the database changes in the terminal:

python manage.py makemigrations
python manage.py migrate

I guess at this point let's just add a single location. We can use the model API to do this pretty easily. Staying at the terminal, the following should do the trick:

python manage.py shell
>>> from maps.models import Location
>>> l = Location(name="Location Location Location", lat=-41.30123, lon=174.78850)
>>> l.save()
>>> exit()

Handling the geoJSON location data

Alright, let's go back to our view now. We're going to add an import for our Location model, get the location we just added, and serialize it into a geoJSON-like object. We'll then include that in our context that is passed to the template:

from django.shortcuts import render
from config.settings import MAPBOX_TOKEN
from maps.models import Location

def index(request):
    location = Location.objects.get(id=1)
    location_json = location.serialize()
    context = {
        'MAPBOX_TOKEN': MAPBOX_TOKEN,
        'location': location_json,
    }
    return render(request, 'index.html', context)

If you were to now add {{location}} inside some p-tag in the template, it'll print out the geoJSON, so you can see what it looks like. Of course, we actually want to add it to the map, so we'll update our template's Javascript with three lines of code: reading in the data from the view, parsing the JSON, and adding it to the map. The final script will look like this:

<script>
    var mymap = L.map('mapid').setView([-41.3, 174.75], 12);
    L.tileLayer('https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={{MAPBOX_TOKEN|escapejs}}', {
        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>, Imagery © <a href="https://www.mapbox.com/">Mapbox</a>',
        maxZoom: 18,
        id: 'mapbox/streets-v11',
        tileSize: 512,
        zoomOffset: -1,
        accessToken: '{{MAPBOX_TOKEN|escapejs}}'
    }).addTo(mymap);

    var raw_data = '{{location|escapejs}}';
    var data = JSON.parse(raw_data);
    L.geoJSON(data).addTo(mymap);
</script>

And that's it! A reminder that the map I created in this example can be found here. The map is built in to the Django project that this website was built on, but I've reproduced the code in a standalone repo in my GitLab account here.

Update: Multiple location objects

Liz raised an interesting question in the comments: How do you implement this with multiple location objects? In hindsight, this is a pretty critical consideration that I probably should have addressed in the original post. In any case, it's a relatively easy modification to implement. The idea is that we need to pass a geoJSON FeatureCollection to Leaflet, rather than the singular Feature objects we've been using above. So let's dive in.

The first thing to do is to add another object into our Location model, and we'll do this in the terminal just like we did earlier:

python manage.py shell
>>> from maps.models import Location
>>> l = Location(name="Another Excellent Location", lat=-41.30000, lon=174.72850)
>>> l.save()
>>> exit()

So, we'll need a couple of modifications. Firstly let's update our model's serialize function. We'll be returning a Python dictionary instead of the json.dumps object, so that we can more easily combine the locations into a JSON FeatureCollection in the view:

class Location(models.Model):
    name = models.CharField(max_length=50)
    lon = models.FloatField()
    lat = models.FloatField()
    
    def serialize(self):
        json_dict = {}
        json_dict['type'] = 'Feature'
        json_dict['properties'] = dict(name=self.name)
        json_dict['geometry'] = dict(type='Point', coordinates=list([self.lon,self.lat]))
        # return(json.dumps(json_dict))

        # update - allow multiple geoms
        # return Py dict (will do json.dumps in view)
        return(json_dict)

Now onto the view. We grab all the Location objects, and iterate over them to compile a list of locations. Then, we create our location dictionary that mimics the structure of a geoJSON FeatureCollection, which is what we need for multiple features. Finally, we dump the JSON out and pass it to the template:

def index(request):
    from json import dumps
    locations = Location.objects.all()
    location_list = [l.serialize() for l in locations]
    location_dict = {
        "type": "FeatureCollection",
        "features": location_list
    }
    location_json = dumps(location_dict)

    context = {
        'MAPBOX_TOKEN': MAPBOX_TOKEN,
        'locations': location_json,
    }
    return render(request, 'index.html', context)

Note that I've changed location to locations, and updated that in the template Javascript as well (i.e., var raw_data = '{{locations|escapejs}}';). And that's all we need to do! Save and reload, and you should see two points on the map now. The main takeaway is that we need to pass Leaflet a geoJSON FeatureCollection rather than a singular Feature. Easy :)

Final tip

Debugging this sort of thing can be tricky. If your points aren't showing on the map, it can be hard to know whether it's a problem with your model, your view, the template, or the geoJSON itself. To verify that your geoJSON is actually in the correct format, set up a breakpoint in your view and have a look at location_dict just before it's passed to the template. You can then copy and paste it into geojson.io to check that it's specified correctly. This saved me a lot of headaches!



6 comments

Stephan 16 April, 2022

Thank you for this tutorial, it was helpful to me. Did something happen to the repo on github? The link does not work anymore.

heds.nz 16 April, 2022

Thanks for pointing that out Stephan, I've fixed the broken link now -- source code should be here for all of eternity now: https://gitlab.com/hedsnz/leaflet-maps-django-geojson

Jane 28 May, 2021

I have got the programme to work thank you; the issue I had was with naming the key - a duplication within settings.py. This is a very helpful programme thank you, as an introduction to using geoJSON witha Leaflet map.

Jane 27 May, 2021

I am trying out your example and ran into an issue loading the map. As you suggest, I got an API token from MapBox and stored it in an environment variable and I set the context variable in the index view to this. The problem is the map won't load unless I set the '?access_token =' to the actual token (rather than to {{MAPBOX_TOKEN|escapejs}} ). Interestingly, the "accessToken = '{{MAPBOX_TOKEN|escapejs}}' " bit of code works fine. Is there something I am overlooking?

Liz 9 April, 2021

What if you wanted to turn that in to a json of all the objects in that model?

Something like this, but this seems to turn it into a string:

location = Location.objects.all()
location_json = [i.serialize() for i in location]

heds.nz 9 April, 2021

Good question Liz, thanks for the comment. I've updated the post showing how to pass multiple objects to the map; hope that helps!

Leave a comment