Adding MapBox Blocks to Wagtail Stream Fields

Introduction

This began as a (relatively) simple idea to be able to add maps as Wagtail blocks on a page. Drop some pins onto a MapBox map and optionally create a route with your choice of transport means.

As I got further in, I realised there were quite a few hurdles to get across to do this:

  1. I needed to create a nested stream block that could store an unknown number of GPS points
  2. Since this block could be repeated on a page, the component ID’s that the MapBox JavaScript would be writing back to needed to be unique.
  3. I needed a way to send a complex data structure from the Django backend to the JavaScript securely and with minimum fuss. Because of the unique ID requirement, this ended up needing a small hack for one of Django’s default filters.

Blocks

Imports

Imports you’ll need for the blocks:

from django.forms.utils import ErrorList
from django.utils.translation import gettext_lazy as _
from wagtail.blocks import (BooleanBlock, CharBlock, ChoiceBlock, ListBlock, StructBlock, TextBlock)
from wagtail.blocks.field_block import IntegerBlock
from wagtail.blocks.struct_block import StructBlockValidationError

Waypoints

What information do we need to construct the map?

The map would have some display properties and a collection of waypoints (GPS coordinates) to add to the map. Since we don’t know the number of waypoints we’ll be adding to each map, we’ll need to create a list block of waypoints within the map block.

What do we need for each waypoint?

  • GPS coordinates. For ease of processing, these should be supplied in decimal notation.
  • Do we want to show the waypoint on the map? Definitely, this should be available, but there may be times you don’t want it (maybe you’re just adding one to force the route along a certain path). Option to display the waypoint then.
  • If we're displaying a pin, chances are you'll also want a label to display on the pin. This should also be optional.

I could have added the latitude/longitude as separate numeric fields – it would have made numeric checking easier, and possibly more robust for locales that use different number formats. For this instance, I left it as one text field so that it could be quickly pasted in from Google Maps - there's a small trade-off between robustness and usability. I do a custom check (clean()) before saving the waypoint to catch any obvious errors. If you split this into two numeric fields, you'll need to amend the clean() method, and update the waypoint constructor in the template tag.

Testing for Numeric Strings in Python

Python's isnumeric() string method will just tell you if every character is numeric and will return false if it contains a decimal point. The following method can be used to check if a string can be cast as a float:

def isfloat(element: str) -> bool:
    try:
        float(element)
        return True
    except ValueError:
        return False

The waypoint block:

class MapWaypointBlock(StructBlock):
    gps_coord = CharBlock(
        label=_('GPS Coordinates (Latitude, Longtitude)'),
        help_text=_('Ensure latitude followed by longitude separated by a comma (e.g. 42.597486, 1.429252).')
        )
    pin_label = TextBlock(
        label=_('Map Pin Label (optional)'),
        help_text=_('Text for map pin pop-up (if used).'),
        required=False
    )
    show_pin = BooleanBlock(
        label=_('Show Pin on Map'),
        default=True,
        required=False
    )
    class Meta:
        icon = 'plus-inverse'
        label = _("Map Waypoint")
        
    def clean(self, value):
        errors = {}
        gps = value.get('gps_coord')

        if gps.count(',') != 1:
            errors['gps_coord'] = ErrorList(
                [_("Please enter latitude followed by longitude, separated by a comma.")]
            )
            raise StructBlockValidationError(block_errors=errors)

        lat, lng = gps.split(',')
        
        if not(isfloat(lat) and isfloat(lng)):
            errors['gps_coord'] = ErrorList(
                [_("Please enter latitude and longitude in numeric format (e.g. 42.603552, 1.442655 not 42°36'12.8\"N 1°26'33.6\"E).")]
            )
            raise StructBlockValidationError(block_errors=errors)

        if (float(lat) < -90 or float(lat) > 90 or float(lng) < -180 or float(lng) > 360):
            errors['gps_coord'] = ErrorList(
                [_("Please enter latitude between -90 and 90 and longitude between -180 and 360.")]
            )
            raise StructBlockValidationError(block_errors=errors)

        return super().clean(value)

We have the three fields for our information decided on, some basic meta, then onto the clean():

  • Check there’s only one comma, if not, raise an error.
  • If so, split on that comma, which will give us two values – check they’re both floats. If not, the coordinates may have been written in non-decimal format (i.e. 42°36'12.8"N instead of 42.603552), an error is raised.
  • Finally, a small check that may catch additional input errors – is the latitude within the range of -90 to 90? Is the longitude within the range of -180 to 360?
  • Note that we need to use StructBlockValidationError here to be able to highlight the fields in error as we’re raising errors inside StructBlocks. Using Django’s ValidationError will raise the error but not highlight the field.
Multi-lingual sites

The above clean method relies on a numerical format similar to English (i.e. a dot for the decimal point rather than a comma). If you're on a site with locales that use commas, or mixed, it would be better to have separate numeric fields for latitude and longitude and let the operating system take care of the formatting.

Map Block

On to the map block itself.

We know we’ll need a collection of waypoints. For this, we can create a ListBlock with our MapWaypointBlock as the list class.

We can set a minimum and maximum since we need at least two to create a route and can only add up to 25 as a limit set by MapBox.

MapBox offers a selection of travel methods – we’ll add these as options in a ChoiceBlock, as well as the option to not display a route at all.

class RouteOptionChoiceBlock(ChoiceBlock):
     choices=[
        ('no-route', "None"),
        ('walking', "Walking"),
        ('cycling', "Cycling"),
        ('driving', "Driving"),
        ('driving-traffic', "Driving (with traffic conditions)")
     ]

More options:

  • We can pull route length and estimated time from MapBox. We'll add this as an option.
  • Map height is a good option – for this, I’ll just use a percentage of the viewport height rather than hardcode pixel sizes.
  • Routes can be sinuous and can extend well outside of the rectangle formed by the extreme points on the map. Add some padding options to allow the whole route to be seen in tricky cases.

There are other options that I could have added but aren't included here – map style (I’m just using the default outdoor map here), adding navigation steps either to the map or to a separate adjacent table, title, etc..

class MapBlock(StructBlock):
    waypoints = ListBlock(
        MapWaypointBlock, 
        min_num=2, 
        max_num=25, 
        label=_("Add Waypoints (minimum 2, maximum 25)")
    )
    route_type = RouteOptionChoiceBlock(default='walking')
    show_route_info = BooleanBlock(
        label=_("Show Route Distance and Duration on Map"),
        default=True,
        required=False
    )
    height = IntegerBlock(default=70, min_value=20,
                    help_text=_("Height of map (% of viewport)."))
    padding_top = IntegerBlock(default=50, min_value=0)
    padding_right = IntegerBlock(default=50, min_value=0)
    padding_bottom = IntegerBlock(default=50, min_value=0)
    padding_left = IntegerBlock(default=50, min_value=0,
                    help_text=_("Pixels from edge of map to closest waypoint."))

    class Meta:
        template='blocks/map_block.html'
        icon="site"
        label = _("Interactive Map")

Site Setting

Before we go to the template tag, MapBox requires an API token which you’ll need to register for.

Rather than hard coding this into the site, it's better to add a site setting to allow it to be updated from the admin site without needing to edit any code:

from wagtail.contrib.settings.models import register_setting
@register_setting(icon='password')
class MapBoxToken(BaseSetting):
    key = models.CharField(
        max_length=100,
        null=True,
        blank=False,
        verbose_name=_("Mapbox Access Token")
    )

I have a site_settings app to keep all these in one place so I import site_settings.models in the template tag below – adjust to your own site.

Template Tags

To pass information easily and securely into JavaScript, we’re going to create a JSON script from a dictionary object containing all of our values. To do this, we’ll create a template tag that takes the block as a parameter and creates the dictionary.

Back at the beginning, I mentioned I would need a unique identifier to distinguish one map from another on the same page. For this, I use the block ID. If you want to use a different UID, you just need to change this definition once here.

This is also where we’ll split that GPS field into latitude and longitude.

from django import template
from site_settings.models import MapBoxToken

register = template.Library()

@register.simple_tag()
def get_map_settings(block):
    try:
        token = getattr(MapBoxToken.objects.first(), 'key')
    except:
        token = ''
        print('MapBox key not found in site settings')
    
    map_settings = {
        'uid': block.id,
        'token': token,
        'route_type' : block.value['route_type'], 
        'show_route_info' : block.value['show_route_info'], 
        'padding' : [
            block.value['padding_top'], 
            block.value['padding_right'], 
            block.value['padding_bottom'], 
            block.value['padding_left']
            ],
        'waypoints' : []
        }
    
    waypoints = block.value['waypoints']
    for waypoint in waypoints:
        latitude, longitude = [round(float(x.strip()),6) for x in waypoint['gps_coord'].split(',')]
        map_settings['waypoints'].append({
            'longitude' : longitude,
            'latitude' : latitude,
            'pin_label' : waypoint['pin_label'],
            'show_pin' : waypoint['show_pin']
        })

    return(map_settings)
Order of Latitude & Longitude

Mapbox is essentially a graphing application. In giving coordinates to Mapbox, it is always (longitude, latitude) in keeping with standard \((x, y)\) geometry format rather than the mapping convention of (latitude, longitude). Make sure to always have the order correct.

There’s a second tag needed, which is the small hack I mentioned at the beginning.

Django includes a default filter to convert dictionaries into JSON scripts:

{{ value|json_script:"hello-data" }}

If value is the dictionary {'hello':'world'}, the output will be:

<script id="hello-data" type="application/json">{"hello": "world"}</script>

Unfortunately, there’s no way to parametrise the script ID with Django's template language. Looking at the filter definition, it calls a json_script() function from django.utils.html with the needed parameters. We can do this from a custom tag instead, which allows us to supply an ID at run-time.

from django.utils.html import json_script
    
@register.simple_tag()
def add_json_script(value, element_id):
    return json_script(value, element_id)

I’ve saved this to map_block_tags.py in the appropriate templatetags folder which I call in the template below.

If this seems like a lot to do each time you load a map, it is, but you should be using template caching before doing this kind of pre-processing so that it only gets performed once.

You should get a dictionary with a structure similar to the following:

{
    "uid": "1234567890b-778b-48a5-9653-62607f4c96d2",
    "token": "your.mapbox.token",
    "route_type": "walking",
    "show_route_info": true,
    "padding": [50, 50, 50, 50],
    "waypoints": [
        {
            "longitude": 11.77624,
            "latitude": 42.1541,
            "pin_label": "a",
            "show_pin": true
        },
        {
            "longitude": 12.128261,
            "latitude": 42.168219,
            "pin_label": "b",
            "show_pin": true
        }
    ]
}

Loading CSS and JavaScript for Blocks on Demand

There are times where you have blocks that rely on some fairly heavy scripts (as in this case), or are just used rarely, and you don't want to load on every page when the block isn't in use.

A method I use is to define a couple of JavaScript functions, one each for CSS and JavaScript. Both functions are passed a file path/url and arbitrary id. The corresponding <link>/<script> tag is generated with the id.

In the case of CSS, if the function is called with the same id again, the function finds that the <link> element already exists and skips loading.

// include css only if not already included
const include_css = (css, id) => {
  let link_tag = document.getElementById(id);
  if (!link_tag) {
    const head = document.head || document.getElementsByTagName('head')[0];
    link_tag = document.createElement('link');
    link_tag.rel = 'stylesheet';
    link_tag.href = css;
    link_tag.id = id;
    head.appendChild(link_tag);
  }
};

In the case of JavaScript, the function initially creates a Promise and adds the <script> tag. It creates onload and onerror events listeners which resolve/reject the Promise. This is useful for waiting for the script to load before continuing with dependent code. If the function is called with the same id again, the function finds that the <script> element already exists, skips loading and resolves the Promise.

// include js script only if not already included
const include_js = (js, id) => {
  return new Promise((resolve, reject) => {
    let script_tag = document.getElementById(id);
    if (!script_tag) {
      const head = document.head || document.getElementsByTagName('head')[0];
      script_tag = document.createElement('script');
      script_tag.type = 'text/javascript';
      script_tag.src = js;
      script_tag.id = id;
      script_tag.onload = resolve; // Resolve the promise when script is loaded
      script_tag.onerror = reject; // Reject the promise on error
      head.appendChild(script_tag);
    } else {
      resolve(); // Resolve the promise if script is already loaded
    }
  });
};

You can also combine prerequisite scripts when calling includejs:

Promise.all([
    include_js('some-script.js', "some-script"),
    include_js('another-script.js', "another-script")
  ]).then(() => {
    doSomething();
  }).catch(error => {
    console.error('Error loading scripts:', error);
  });

In this example, doSomething() won't run until both scripts have completed loading.

I keep both of these in my global site JavaScript so they are always available to block templates.

Template

Before I start on this, a note that I’m writing templates for multi-lingual sites. For text appearing directly in templates, I use a system I created called template_sets. If you’re not using multi-language, or using a different system, drop the core_tags and anything calling trans in the below code. If you’d like to know more about using the template_set tags, you can read about it in this blog.

Stepping through the template below:

  • Load the template tags.
  • Use the template tag to create the settings dictionary, then make a JSON script from that with the UID as the component ID.
  • Set up the map container with the UID as a suffix (the map will be drawn to this container) and set the container height from the value chosen in the map block. Note: map container element needs to be empty, otherwise MapBox will throw an error.
  • Underneath the container, we display the route summary if required. The initial display style is none to hide the text while the route is processed. It will be set to block once the info has been returned from MapBox.
  • I'm adding a style definition for the map-block container which is required by MapBox to position the map correctly. This is better placed in your site CSS.
  • Finally, we call our custom JavaScript function, which is detailed later. The function will take the UID so that it can pull in the correct data set. I’m using the includejs function detailed in the previous section to ensure map-block.js is loaded only once per page and that it is loaded before calling the draw_mapblock() method found in that file.
{% load static map_block_tags core_tags %}
{% get_template_set "maps" as trans %}
{% get_map_settings block as map_settings %}
{% add_json_script map_settings map_settings.uid %}
<div class="block-container">
  <div id="map-{{ map_settings.uid }}"
       class="map-block"
       style="height:{{ self.height }}vh"></div>
  {% if self.show_route_info and self.route_type != "no-route" %}
    <div id="routesummary-{{ map_settings.uid }}"
         class="map-block-summary-container">
      {{ trans.route_length }}: <span id="distance-{{ map_settings.uid }}"></span>{{ trans.km }},
      {{ trans.approximate_time }} <span id="hours-{{ map_settings.uid }}"></span> {{ trans.hours }}.
    </div>
  {% endif %}
</div>
<style>
div.map-block {
  position: relative;
  top: 0px;
  right: 0px;
  width: 100%;
}
</style>
<script>
  include_js("{% static 'js/map-block.js' %}", "js-map-block")
  .then(() => {
    draw_mapblock("{{ map_settings.uid }}");
  });
</script>

JavaScript

Last but not least, the JavaScript. This is where the meat of the map functionality happens.

Much of the following came from ideas I scraped from the MapBox help section on directions - mostly the first example where the user can click two points to draw a map route.

I’ll put the full map-block.js file below and then break it up to step through it afterwards:

//========map block ============

// get the map block settings
const draw_mapblock = (uid) => {
  const map_settings = JSON.parse(document.getElementById(uid).textContent);
  include_css("https://api.tiles.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.css", "mapbox-gl-css");
  include_js("https://api.tiles.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.js", "mapbox-gl-js")
    .then(() => {
      add_mapbox(map_settings);
    });
};

// add the map using supplied settings
const add_mapbox = (map_settings) => {
  // map_settings should be similar structure to the below with max 25 waypoints
  // {
  //   "uid": "block.id",
  //   "token": "your.mapbox.token",
  //   "route_type": "walking",
  //   "show_route_info": true,
  //   "padding": [50, 50, 50, 50],
  //   "waypoints": [
  //       {"longitude": 11.77624, "latitude": 42.1541, "pin_label": "a", "show_pin": true},
  //       {"longitude": 12.128261, "latitude": 42.168219, "pin_label": "b", "show_pin": true}
  //   ]
  // }

  // create base map object
  mapboxgl.accessToken = map_settings.token;
  const map = new mapboxgl.Map({
    container: `map-${map_settings.uid}`,
    style: "mapbox://styles/mapbox/outdoors-v12",
  });
  map.addControl(new mapboxgl.NavigationControl());
  map.addControl(new mapboxgl.ScaleControl({ position: "bottom-right" }));
  // map.scrollZoom.disable();

  // set the initial bounds of the map, bound set again after route loads
  const arrayColumn = (arr, n) => arr.map((x) => x[n]);
  const min_lat = Math.min(...arrayColumn(map_settings.waypoints, "latitude"));
  const max_lat = Math.max(...arrayColumn(map_settings.waypoints, "latitude"));
  const min_lng = Math.min(...arrayColumn(map_settings.waypoints, "longitude"));
  const max_lng = Math.max(...arrayColumn(map_settings.waypoints, "longitude"));
  map.fitBounds(
    [
      [min_lng, min_lat],
      [max_lng, max_lat],
    ],
    {
      padding: {
        top: map_settings.padding[0],
        right: map_settings.padding[1],
        bottom: map_settings.padding[2],
        left: map_settings.padding[3],
      },
    }
  );

  // add layers and markers after base map loads
  map.on("load", () => {
    if (map_settings.method !== "no-route") {
      getRoute(map_settings.waypoints);
      // Add starting and end points to the map
      map.addLayer({
        id: "start",
        type: "circle",
        source: {
          type: "geojson",
          data: {
            type: "FeatureCollection",
            features: [
              {
                type: "Feature",
                properties: {},
                geometry: {
                  type: "Point",
                  coordinates: [
                    map_settings.waypoints[0].longitude,
                    map_settings.waypoints[0].latitude,
                  ],
                },
              },
            ],
          },
        },
        paint: {
          "circle-radius": 10,
          "circle-color": "#3887be",
        },
      });
      const end_index = map_settings.waypoints.length - 1;
      map.addLayer({
        id: "end",
        type: "circle",
        source: {
          type: "geojson",
          data: {
            type: "FeatureCollection",
            features: [
              {
                type: "Feature",
                properties: {},
                geometry: {
                  type: "Point",
                  coordinates: [
                    map_settings.waypoints[end_index].longitude,
                    map_settings.waypoints[end_index].latitude,
                  ],
                },
              },
            ],
          },
        },
        paint: {
          "circle-radius": 10,
          "circle-color": "#f30",
        },
      });
    }

    // add markers with Google Maps links
    map_settings.waypoints.forEach((waypoint) => {
      if (waypoint.show_pin) {
        const marker = new mapboxgl.Marker()
          .setLngLat([waypoint.longitude, waypoint.latitude])
          .setPopup(
            new mapboxgl.Popup().setHTML(
              "<b>" +
              waypoint.pin_label +
              "</b><br>" +
              `<a href="https://www.google.com/maps?q=${waypoint.latitude},${waypoint.longitude}" 
                       target="_blank">${waypoint.latitude}, ${waypoint.longitude}</a>`
            )
          ) // add popup
          .addTo(map);
      }
    });
  });

  // create a function to make a directions request
  const getRoute = async (coord_list) => {
    // build the gps points query string
    const points = coord_list.map((coord) => [coord.longitude, coord.latitude].join());
    const gps_list = points.join(";");
    const query = await fetch(
      `https://api.mapbox.com/directions/v5/mapbox/${map_settings.route_type}/${gps_list}?steps=false&geometries=geojson&access_token=${mapboxgl.accessToken}`,
      { method: "GET" }
    );
    // request json data
    const json = await query.json();
    const data = json.routes[0];
    const route = data.geometry.coordinates;
    const geojson = {
      type: "Feature",
      properties: {},
      geometry: {
        type: "LineString",
        coordinates: route,
      },
    };
    map.addLayer({
      id: `route-${map_settings.uid}`,
      type: "line",
      source: {
        type: "geojson",
        data: geojson,
      },
      layout: {
        "line-join": "round",
        "line-cap": "round",
      },
      paint: {
        "line-color": "#3887be",
        "line-width": 5,
        "line-opacity": 0.75,
      },
    });

    // send route length info back to page
    if (map_settings.show_route_info) {
      document.getElementById(`distance-${map_settings.uid}`).innerText = (
        Math.round(data.distance / 100) / 10
      ).toFixed(1);
      document.getElementById(`hours-${map_settings.uid}`).innerText = (
        Math.round(data.duration / 360) / 10
      ).toFixed(1);
      document.getElementById(`routesummary-${map_settings.uid}`).style.display =
        "block";
    }

    // set map bounds to fit route
    const bounds = new mapboxgl.LngLatBounds(route[0], route[0]);
    for (const coord of route) {
      bounds.extend(coord);
    }
    map.fitBounds(bounds, {
      padding: {
        top: map_settings.padding[0],
        right: map_settings.padding[1],
        bottom: map_settings.padding[2],
        left: map_settings.padding[3],
      },
    });
  };

};

draw_mapblock

// get the map block settings
const draw_mapblock = (uid) => {
  const map_settings = JSON.parse(document.getElementById(uid).textContent);
  include_css("https://api.tiles.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.css", "mapbox-gl-css");
  include_js("https://api.tiles.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.js", "mapbox-gl-js")
    .then(() => {
      add_mapbox(map_settings);
    });
};

We start off by just retrieving the data from the JSON script and then passing this on to the main function. Why do this? Well, maybe you’re not coming from Django/Wagtail and already have the data ready to go. Less recoding that way. If that’s the case, be sure to look at the JSON object structure above first.

Back in the template, we added the JSON script with the tag ID set to the UID of the block. We need to retrieve that data using the same UID.

We also need to load the MapBox api css and js, and to wait for that js to load before proceeding.

add_mapbox

// add the map using supplied settings
const add_mapbox = (map_settings) => {
  // map_settings should be similar structure to the below with max 25 waypoints
  // {
  //   "uid": "block.id",
  //   "token": "your.mapbox.token",
  //   "route_type": "walking",
  //   "show_route_info": true,
  //   "padding": [50, 50, 50, 50],
  //   "waypoints": [
  //       {"longitude": 11.77624, "latitude": 42.1541, "pin_label": "a", "show_pin": true},
  //       {"longitude": 12.128261, "latitude": 42.168219, "pin_label": "b", "show_pin": true}
  //   ]
  // }

  // create base map object
  mapboxgl.accessToken = map_settings.token;
  const map = new mapboxgl.Map({
    container: `map-${map_settings.uid}`,
    style: "mapbox://styles/mapbox/outdoors-v12",
  });
  map.addControl(new mapboxgl.NavigationControl());
  map.addControl(new mapboxgl.ScaleControl({ position: "bottom-right" }));
  // map.scrollZoom.disable();

  // set the initial bounds of the map, bound set again after route loads
  const arrayColumn = (arr, n) => arr.map((x) => x[n]);
  const min_lat = Math.min(...arrayColumn(map_settings.waypoints, "latitude"));
  const max_lat = Math.max(...arrayColumn(map_settings.waypoints, "latitude"));
  const min_lng = Math.min(...arrayColumn(map_settings.waypoints, "longitude"));
  const max_lng = Math.max(...arrayColumn(map_settings.waypoints, "longitude"));
  map.fitBounds(
    [
      [min_lng, min_lat],
      [max_lng, max_lat],
    ],
    {
      padding: {
        top: map_settings.padding[0],
        right: map_settings.padding[1],
        bottom: map_settings.padding[2],
        left: map_settings.padding[3],
      },
    }
  );

  // add layers and markers after base map loads
  map.on("load", () => {
      // described later
  });

  // create a function to make a directions request
  const getRoute = async (coord_list) => {
      // described later
  };

};

Breaking this into chunks, we’ll start off with the initialisation:

  • Set the token from the value in the settings.
  • Create the Map object with the target container ID (‘map-UID’) and map style. Again, v12 is the current version at the time of writing. Check this before using it. I’ve hardcoded the default outdoors map here. You could change this to another map or to a parametrised value. You can also use MapBox Studio to create your own maps and call them here.
  • Add navigation and scale buttons.
  • A commented out command to disable mouse wheel zooming is left in (map.scrollZoom.disable();), in case you prefer to do this.
  • Next, set the initial bounds of the map based on the waypoints. Take the minimum and maximum of both latitude and longitude to set this. Doing this means the map is more or less in the right place while waiting for the route to load. Without it, you have a world map for a couple of seconds beforehand.

map on load event

// add layers and markers after base map loads
  map.on("load", () => {
    if (map_settings.method !== "no-route") {
      getRoute(map_settings.waypoints);
      // Add starting and end points to the map
      map.addLayer({
        id: "start",
        type: "circle",
        source: {
          type: "geojson",
          data: {
            type: "FeatureCollection",
            features: [
              {
                type: "Feature",
                properties: {},
                geometry: {
                  type: "Point",
                  coordinates: [
                    map_settings.waypoints[0].longitude,
                    map_settings.waypoints[0].latitude,
                  ],
                },
              },
            ],
          },
        },
        paint: {
          "circle-radius": 10,
          "circle-color": "#3887be",
        },
      });
      const end_index = map_settings.waypoints.length - 1;
      map.addLayer({
        id: "end",
        type: "circle",
        source: {
          type: "geojson",
          data: {
            type: "FeatureCollection",
            features: [
              {
                type: "Feature",
                properties: {},
                geometry: {
                  type: "Point",
                  coordinates: [
                    map_settings.waypoints[end_index].longitude,
                    map_settings.waypoints[end_index].latitude,
                  ],
                },
              },
            ],
          },
        },
        paint: {
          "circle-radius": 10,
          "circle-color": "#f30",
        },
      });
    }

    // add markers with Google Maps links
    map_settings.waypoints.forEach((waypoint) => {
      if (waypoint.show_pin) {
        const marker = new mapboxgl.Marker()
          .setLngLat([waypoint.longitude, waypoint.latitude])
          .setPopup(
            new mapboxgl.Popup().setHTML(
              "<b>" +
                waypoint.pin_label +
                "</b><br>" +
                `<a href="https://www.google.com/maps?q=${waypoint.latitude},${waypoint.longitude}" 
                       target="_blank">${waypoint.latitude}, ${waypoint.longitude}</a>`
            )
          ) // add popup
          .addTo(map);
      }
    });
  });
  • If showing some type of route, call the getRoute subroutine (more details later) and add blue and red spots to mark the first and last waypoints on the route.
  • For each waypoint where show_pin was selected, add the pin on the map with its label. I’m also adding a Google Map link to that GPS coordinate.

getRoute()

The final part of the jigsaw is the getRoute function which is an async call that sets up a promise and returns all the route info:

// create a function to make a directions request
  const getRoute = async (coord_list) => {
    // build the gps points query string
    const points = coord_list.map((coord) => [coord.longitude, coord.latitude].join());
    const gps_list = points.join(";");
    const query = await fetch(
      `https://api.mapbox.com/directions/v5/mapbox/${map_settings.route_type}/${gps_list}?steps=false&geometries=geojson&access_token=${mapboxgl.accessToken}`,
      { method: "GET" }
    );
    // request json data
    const json = await query.json();
    const data = json.routes[0];
    const route = data.geometry.coordinates;
    const geojson = {
      type: "Feature",
      properties: {},
      geometry: {
        type: "LineString",
        coordinates: route,
      },
    };
    map.addLayer({
      id: `route-${map_settings.uid}`,
      type: "line",
      source: {
        type: "geojson",
        data: geojson,
      },
      layout: {
        "line-join": "round",
        "line-cap": "round",
      },
      paint: {
        "line-color": "#3887be",
        "line-width": 5,
        "line-opacity": 0.75,
      },
    });

    // send route length info back to page
    if (map_settings.show_route_info) {
      document.getElementById(`distance-${map_settings.uid}`).innerText = (
        Math.round(data.distance / 100) / 10
      ).toFixed(1);
      document.getElementById(`hours-${map_settings.uid}`).innerText = (
        Math.round(data.duration / 360) / 10
      ).toFixed(1);
      document.getElementById(`routesummary-${map_settings.uid}`).style.display =
        "block";
    }

    // set map bounds to fit route
    const bounds = new mapboxgl.LngLatBounds(route[0], route[0]);
    for (const coord of route) {
      bounds.extend(coord);
    }
    map.fitBounds(bounds, {
      padding: {
        top: map_settings.padding[0],
        right: map_settings.padding[1],
        bottom: map_settings.padding[2],
        left: map_settings.padding[3],
      },
    });
  };
  • Build a GPS list for the API query string (a list of lng/lat pairs separated by semicolons)
  • Make the API call
    • check the version if you do this (currently v5)
    • steps=false is hardcoded here, change this if you want to access the route steps
  • The first route is used and assigned to the data variable – MapBox actually returns a list of routes which you can take advantage of
  • The route layer is added (note I suffix 'route' with the UID to make sure it’s unique on the page)
  • If the option to show the route summary was selected, send this data to the appropriate elements and display the summary container.
  • Lastly, gather all the step coordinates from the route and fit the map to show all of these

The Finished Product

Well, nothing is "finished" if you're truly Agile right? Here's the map block in action, the first leg of a 10-day hike I made last year. This is also a good example of when it's a good idea to not trust calculated estimates of hiking times at face value. 3 hours is plenty of time for 8km on gentle terrain; this took me a solid 6 hours, and I'm a fast hiker. Terrain makes all the difference. That's another subject altogether ...

Route length: km, approximate time hours.

Conclusion

Simple ideas can become surprisingly complex when you dig a bit deeper. This exercise threw up quite a few more challenges than expected, but was also a good exercise in thinking through workarounds to unusual problems.

  • Passing data structures from Django's backend to JavaScript securely
  • Passing extra parameters into Django's default filters (in this case, json_script to achieve dynamic component ID's)
  • Nesting ListBlocks within StructBlocks
  • Adding site settings to avoid hard coding values

The example here is a fairly basic use of MapBox, to display a route between waypoints. It could easily be adapted to show the route to your business from the customer's current location for example or other types of dynamic maps with user interaction.

MapBox is also very good for displaying GIS data. The Earthquake app demonstrated on a previous blog was written with MapBox to display earthquake size and location. Maybe you can use MapBox to display live location-based data or showcase a venue with a fly-through. There are many uses.

While making a block like this that allows you to add multiple maps to a single page, take care not to slow your page loading too much. The outdoor map style used here is quite heavy. You can use a lighter style, such as the street map, depending on your purpose.


  Please feel free to leave any questions or comments below, or send me a message here