Web development: National Parks locator

Maps help people work out where they are. In this tutorial, we'll build an interactive locator, to help users find features on a map. At Ordnance Survey we love helping people explore the natural beauty of Great Britain, so this project will help users location national parks - but it could just as easily be built to locate stores, offices, railway stations, hospitals and so on.

Tools and APIs

We use data the OS Maps and OS Features APIs , as well as Leaflet, leaflet-omnivore and jQuery to build this interactive web interface.

Tutorial

Our locator app will have two main areas of the interface: a list of features to locate in a panel, and a map with those features displayed.

To make things interesting we are going to use JavaScript to interactively connect the map and list representations of the national parks - when the user hovers on a national park on the list, the corresponding geometry should highlight, and vice versa. We will also implement event handlers so when a user clicks on either the <li> or the polygon, the map zooms to that national park.

The Data

We created a lightweight JSON file, /data/national-parks.json. The file contains a GeoJSON FeatureCollection. Each Feature in the collection represents a national park. Each Feature's property attribute contains the name and a url for the park, plus an id.

{
    "type": "FeatureCollection",
    "features": [
        {
            "type": "Feature",
            "properties": {
                "id": 1,
                "name": "Lake District National Park",
                "url": "https://www.lakedistrict.gov.uk/"
            },
            "geometry": {
                "type": "Polygon",
                "coordinates": [
                    [[ -2.671007, 54.47243 ],
                    [ -2.666572, 54.47255 ],
                    [ -2.647861, 54.479841 ],
                    /* ... an array of polygon vertices */
                    ]
                ]
            }
        },
        { /* National Park Feature #2 */ },
         /* ... and so on, for each national park  */
]

(As a quick aside - to create this file we fetched national park geometries from the OS Features API's Open Zoomstack layer. To simplify the geometries and make this GeoJSON more lightweight, we used mapshaper.org's awesome Simplify tool. And we used geojson-precision to trim unnecesary coordinate precision, further reducing file size.)

The HTML

The index.html document contains elements ready to accept content dynamically generated by looping through the national-parks.json file.

Crucially, the HTML includes a <ul class="layers"> element, as well as a <div id="map">. These two elements are containers for content.

The JavaScript

In tutorial.js the key logic of the locator app is defined. Here we'll walk through line by line to explain how it works.

Setting up the map

First, a new Leaflet map object is instantiated and a basemap is added using the OS Maps API. (We'll connect to the ZXY version of the API in this project.) This populates the #map div with an OS map.

// API key - required to fetch data from the OS Maps API
var config = { apikey: "YOUR_KEY_HERE" };

// Define map options including where the map loads and zoom constraints
var mapOptions = {
  minZoom: 7,
  maxZoom: 20,
  center: [54.92240688263684, -5.84949016571045],
  zoom: 7,
  attributionControl: false,
  zoomControl: false
};

// Instantiate a new L.map object
var map = new L.map("map", mapOptions);

// Add scale control to the map.
var ctrlScale = L.control.scale({ position: "bottomright" }).addTo(map);

// Load and display ZXY tile layer on the map.
const endpoints = {
  zxy: "https://api.os.uk/maps/raster/v1/zxy"
};

var basemap = L.tileLayer(
  endpoints.zxy + "/Outdoor_3857/{z}/{x}/{y}.png?key=" + config.apikey,
  {
    maxZoom: 20
  }
).addTo(map);

With that our basemap is set up. Next we need to fetch and parse the GeoJSON in national-parks.json. We'll add the FeatureCollection as a layer to our map and attach event listeners to each Feature. We'll also loop through the array of Features and add a <li> customised for each national park.

Event Callbacks

Before we get to the fetch and parse logic though, let's define our event listeners. We'll write functions to highlight and unhighlight both of the visual elements we use to represent each national park: a <li> element and a GeoJSON Feature on the map.

We included the id as one of each Feature's properties so we could have a unique reference to each national park. With this id we'll be able to select the right feature from the GeoJSON layer we add to the map, and select the right <li> (by a data-np-id attribute) with jQuery. We'll write all of our highlight/unhighlight functions based on this ID.

We also wrote a function - flyToBoundsOffset() - to fly to a feature accounting for the lefthand panel that is covering part of the map div.

Looking at the code:

// A helper function to select the L.geoJSON Feature layer by its properties.id value.
// For those looking to adapt the code - this version will only work with unique properties ...
function getFeatureById(dataId) {
  let filtered = Object.values(map._layers).filter((l) => {
    if ("feature" in l) {
      return l.feature.properties.id == dataId;
    }
  });

  return filtered[0];
}

// Highlights a map feature by ID.
function highlightGeojson(dataId) {
  let geojson = getFeatureById(dataId);
  geojson.setStyle({
    fillOpacity: 0.6,
    weight: 3
  });
}

// Unhighlights a map feature by ID.
function unhighlightGeojson(dataId) {
  let geojson = getFeatureById(dataId);
  geojson.setStyle({
    fillOpacity: 0.3,
    weight: 1
  });
}

// Highlights a li element by data-np-id=ID.
function highlightListElement(dataId) {
  $('[data-np-id="' + String(dataId) + '"]').addClass("highlight");
}

// Unhighlights a li element by data-np-id=ID.
function unhighlightListElement(dataId) {
  $('[data-np-id="' + String(dataId) + '"]').removeClass("highlight");
}

// Animated map.flyToBounds() with padding to account for the panel covering part of the map.
function flyToBoundsOffset(dataId, elPosition = "left") {
  let offset = os.main.viewportPaddingOptions()[elPosition];

  let geojsonLayer = getFeatureById(dataId);

  let paddingOptions;

  if (elPosition == "left") {
    paddingOptions = {
      paddingTopLeft: [offset, 50],
      paddingBottomRight: [50, 50]
    };
  } else if (elPosition == "right") {
    paddingOptions = {
      paddingTopLeft: [50, 50],
      paddingBottomRight: [offset, 50]
    };
  }

  map.flyToBounds(geojsonLayer.getBounds(), paddingOptions);
}

Now, the GeoJSON

Because we're loading data from an external resource, we need to make sure that the data loads before we move on to subsequent lines. We will use a handy library called leaflet-omnivore to fetch the GeoJSON file we've prepared and load it into a L.geoJSON object.

When the omnivore.geojson() method has loaded the data, it fires a 'ready' event. We'll place the code that relies on the GeoJSON inside this event handler so we can be sure that it only is executed once the data has loaded.

Creating a custom L.geoJSON object

Omnivore fetches GeoJSON and instantiates a L.geoJSON object. We want to customize ours, so we're going to define a custom L.geoJSON object before we actually load the GeoJSON.

This pattern lets us bind event listeners to each feature, using Leaflet's onEachFeature option. (Note: here with Leaflet layer refers to each Feature in the FeatureCollection.)

// Set up the Leaflet GeoJSON object.
// We'll pass this into the omnivore.geojson() method shortly
var parksLayer = L.geoJSON(null, {
  style: {
    fillColor: os.palette.sequential.s2[3],
    color: os.palette.sequential.s2[6],
    fillOpacity: 0.3,
    weight: 1
  },

  // A function to be called for each Feature in the FeatureCollection
  onEachFeature: function (feature, layer) {
    layer.on({
      mouseover: function (e) {
        highlightGeojson(feature.properties.id);
        highlightListElement(feature.properties.id);
      },
      mouseout: function (e) {
        unhighlightGeojson(feature.properties.id);
        unhighlightListElement(feature.properties.id);
      },
      click: function (e) {
        flyToBoundsOffset(feature.properties.id);
      }
    });
  }
});

Load and Process the GeoJSON FeatureCollection

Next we use Omnivore to fetch the external resource, parse the GeoJSON and add it to the custom L.geoJSON object we've defined (using the L.geoJSON().addData() method under the hood). Note that because this is an asynchronous operation, code reliant on the layer being loaded is placed inside the .on('ready', function () { ... }) event handler callback.

Inside the callback we loop through the Features in the GeoJSON FeatureCollection, adding a <li> to our lefthand panel <ul> element with park-specific data.

After everything is said and done we take the layer we've created and .addTo(map).

// Then fetch the geojson using Leaflet Omnivore, which returns a L.geoJSON object
var nationalParks = omnivore.geojson('./data/national-parks.json', null, parksLayer)
    .on('ready', function () { // <- this callback is executed once data is loaded

Alright, our geographic features are added to the L.geoJSON object with event handlers bound. Now let's set up the lefthand panel with <li> elements.

Making a <li>

To make our list we loop through the array of features in the L.geoJSON object. In the loop, we will be creating a <li> element with park-specific data to place in the left panel. Then we'll attach event listeners and append it to the <ul class="layers"> defined in index.html.

    // This code is being executed inside the omnivore.geojson().on('ready') callback.
    // Remember that nationalParks is a L.geoJSON object - with a .getLayers() method
    nationalParks.getLayers().forEach(function (nationalParkFeature, i) {

        let nationalPark = nationalParkFeature.feature; // <- the GeoJSON Feature object

        // First create the HTML element that will represent the park
        let element = `<li class="layer" data-np-id="${nationalPark.properties.id}">
            <div class="layer-element icon" data-type="list-item" data-id="${nationalPark.properties.id}">
                <div class="label">
                    <img class='np-arrow' src='./img/np-arrow.png' />
                    <span class='np-name'>${ nationalPark.properties.name }
                        </span>
                        <a href="${ nationalPark.properties.url }" target="_blank">
                            <i class="material-icons"
                                onClick="this.href='${ nationalPark.properties.url }'"
                                aria-label="">launch</i>
                        </a>
                </div>
            </div>
        </li>`

        element = $.parseHTML(element);

        // Set up event handlers for the <li> elements and children
        $(element).find('span').on('click', function (e) {
            e.preventDefault();
            // Fly to the feature
            flyToBoundsOffset(nationalPark.properties.id)
        });

        // Highlight on mouseenter ...
        $(element).on('mouseenter', function () {
            highlightGeojson(nationalPark.properties.id)
            highlightListElement(nationalPark.properties.id)
        });

        // ... and unhighlight on mouseleave
        $(element).on('mouseleave', function () {
            unhighlightGeojson(nationalPark.properties.id)
            unhighlightListElement(nationalPark.properties.id)
        });

        // Append the <li> to the <ul>
        $('.layers').append(element);
    });
})
.on('error', function (err) {
    console.error(err);
})
.addTo(map) // <- And add the L.geoJSON object to the map

And that's it! Our app fetches the national parks geometries, loads them, adds them to an unordered list on the left panel, and attaches event handlers to highlight and fly to the appropriate park on click. We included an external link icon so users could visit the national park's official website. The perfect launchpad for exploring Great Britain's amazing national parks.

Feel free to adapt this code to suit your needs. If you make anything cool with OS data - let us know!

Last updated