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.
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!
7 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!
Leon 28 January, 2023
Wondering if using django-geojson would be beneficial. Did you try that approach?