Tutorial

Map Australian Emergency Incidents with Mapbox GL JS

Render live incidents as vector layers in Mapbox GL JS, including the boundary polygons agencies publish for larger fires. This is the heavier of our two mapping guides. If you want a map on a page in fifteen minutes with no account and no token, start with the Leaflet guide instead.

Time
~25 minutes
Requirements
a free DataQuoll key, a Mapbox account and access token

Leaflet or Mapbox

Both render the same data. They are not interchangeable, and picking on familiarity rather than fit is how projects end up fighting their map library.

LeafletMapbox GL JS
Account or tokenNoneRequired, and billed past the free tier
RenderingRaster tiles, DOM markersVector tiles, WebGL
Hundreds of featuresSlows down, markers are DOM nodesComfortable
Styling by data valueWrite the branching yourselfExpressions, evaluated in the style
Polygons over a basemapWorks, no fill control to speak ofFill, outline and opacity per feature
Offline or air-gappedStraightforwardNeeds a self-hosted style and tiles

The short version: Leaflet if you want a map, Mapbox if the map is the product. The one technical reason to reach for Mapbox specifically with this API is polygons, which is the next section.

Why polygons are the reason to be here

Most incidents are a point. Larger ones are not. When an agency publishes a fire perimeter or a warning area, DataQuoll carries it through as a boundary on the incident rather than reducing it to a dot, and a 40,000 hectare fire drawn as a single marker tells a reader almost nothing useful about where it is.

Ask for them with includeBoundary=true. They are opt-in because the geometry is large and most callers do not want it on every request.

Step 1: Keys and tokens

Get a free DataQuoll key, and a public access token from your Mapbox account dashboard.

Both end up in browser JavaScript, so both need restricting. Scope the Mapbox token to your URL in their dashboard. Restrict the DataQuoll key to your origin in yours, under the key's Origins editor: the key then only answers calls made from a browser on your site, so copying it out of your page source gets somebody nothing.

Step 2: The page

One file. The API returns GeoJSON, and Mapbox consumes GeoJSON, so the response goes straight into a source with no conversion step.

incident-map.html
<!DOCTYPE html>
<html lang="en-AU">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Live Australian emergency incidents</title>
  <link href="https://api.mapbox.com/mapbox-gl-js/v3.7.0/mapbox-gl.css" rel="stylesheet" />
  <script src="https://api.mapbox.com/mapbox-gl-js/v3.7.0/mapbox-gl.js"></script>
  <style>
    body { margin: 0; }
    #map { position: absolute; inset: 0; }
  </style>
</head>
<body>
<div id="map"></div>
<script>
  mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN';        // restrict this to your URL
  const DATAQUOLL_KEY = 'YOUR_DATAQUOLL_KEY';        // restrict this to your origin

  const map = new mapboxgl.Map({
    container: 'map',
    style: 'mapbox://styles/mapbox/light-v11',
    center: [133.8, -25.3],   // lng, lat -- GeoJSON order, not lat/lng
    zoom: 3.6,
  });

  // The warning levels, worst first. Used by BOTH layers below so a polygon and
  // its marker can never disagree about what colour "watch and act" is.
  const LEVEL_COLOURS = [
    'match', ['get', 'warningLevel'],
    'emergency_warning', '#b91c1c',
    'watch_and_act',     '#ea580c',
    'advice',            '#2563eb',
    '#6b7280',           // anything else, including none
  ];

  async function loadIncidents() {
    // includeBoundary is OPT-IN. Without it every incident is a point, and a
    // 40,000 hectare fire renders as one dot in the middle of it.
    const params = new URLSearchParams({ includeBoundary: 'true', limit: '500' });

    const res = await fetch('https://dataquoll.io/api/v1/incidents?' + params, {
      headers: { Authorization: 'Bearer ' + DATAQUOLL_KEY },
    });
    if (!res.ok) throw new Error('DataQuoll returned ' + res.status);
    const geojson = await res.json();

    // Boundaries arrive as a "boundary" PROPERTY, not as the feature geometry:
    // the geometry stays a point so that markers, clustering and the nearby
    // endpoint all keep working. Split them into their own collection here.
    const areas = {
      type: 'FeatureCollection',
      features: geojson.features
        .filter((f) => f.properties.boundary)
        .map((f) => ({ ...f, geometry: f.properties.boundary })),
    };

    map.getSource('incidents').setData(geojson);
    map.getSource('incident-areas').setData(areas);
  }

  map.on('load', async () => {
    map.addSource('incidents', { type: 'geojson', data: emptyCollection() });
    map.addSource('incident-areas', { type: 'geojson', data: emptyCollection() });

    // Polygons UNDER the points, so a marker is never hidden by its own fire.
    map.addLayer({
      id: 'areas-fill',
      type: 'fill',
      source: 'incident-areas',
      paint: { 'fill-color': LEVEL_COLOURS, 'fill-opacity': 0.25 },
    });
    map.addLayer({
      id: 'areas-outline',
      type: 'line',
      source: 'incident-areas',
      paint: { 'line-color': LEVEL_COLOURS, 'line-width': 1.5 },
    });

    map.addLayer({
      id: 'incident-points',
      type: 'circle',
      source: 'incidents',
      paint: {
        'circle-color': LEVEL_COLOURS,
        // Interpolated by zoom so the map is readable at both ends.
        'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 3, 10, 8],
        'circle-stroke-width': 1,
        'circle-stroke-color': '#ffffff',
      },
    });

    map.on('click', 'incident-points', (e) => {
      const f = e.features[0];
      const p = f.properties;
      new mapboxgl.Popup()
        .setLngLat(f.geometry.coordinates)
        .setHTML(
          '<strong>' + p.title + '</strong><br>' +
          (p.location ? JSON.parse(p.location).suburb || '' : '') + '<br>' +
          'Warning level: ' + (p.warningLevel || 'none')
        )
        .addTo(map);
    });
    map.on('mouseenter', 'incident-points', () => { map.getCanvas().style.cursor = 'pointer'; });
    map.on('mouseleave', 'incident-points', () => { map.getCanvas().style.cursor = ''; });

    await loadIncidents();
    setInterval(loadIncidents, 300000);   // 5 minutes; see the note on quota below
  });

  function emptyCollection() {
    return { type: 'FeatureCollection', features: [] };
  }
</script>
</body>
</html>

Three things that catch people out

  • Pin the CDN script before you ship it. The tag above loads Mapbox GL JS straight from their CDN, which is fine while you are building and not what you want in production: whoever serves that file can run code on your page. Add integrity and crossorigin attributes using the hash Mapbox publishes for the exact version you pinned, or serve the library from your own origin. Take the hash from their release notes rather than from anywhere it has been copied to.
  • Coordinate order. GeoJSON is [longitude, latitude], which is the reverse of how most people say it. A swapped Australian pair lands in the Indian Ocean and still looks like a valid coordinate, so it is worth checking the first time rather than wondering why the map is empty.
  • Properties arrive as strings. Mapbox serialises nested GeoJSON properties, so location and details need parsing back inside a popup, as above. Top-level strings like warningLevel are fine to use directly in an expression.
  • Refresh interval against your quota. Every browser tab polls independently. At five minutes each, ten people with the map open is around 86,000 calls a month. Either raise the interval, or proxy the request through your own server so one fetch serves everyone, which is the better shape for a public map anyway.

Where to go next