Topic 5: Modules, Bundlers, Web Mapping with OpenStreetMap and Leaflet

This week we will look at web mapping with the Leaflet library. We will also look at JavaScript modules and incorporating dependencies into client-side applications with bundlers.

Introduction

Websites and smartphone apps which show maps are very common these days. Many such sites use commercial mapping providers such as Google Maps and the like. However, such providers place restrictions on their users and the maps are often generic and do not show information for specialised users such as walkers and cyclists.

OpenStreetMap is a project to produce free, editable maps of the entire world. Users can contribute their own mapping data and the data can be used for free by anyone; see the OpenStreetMap site for more details. Users typically survey a road or path with a GPS device, such as a smartphone, and then draw the road or path on top of their GPS trace using editing software. The fact that the data is free means that developers can use it for their own pruposes, for instance, create their own maps or develop routing applications.

Leaflet is an open-source JavaScript mapping library which offers similar functionality to commercial web mapping services such as Google Maps. It allows you to embed a "slippy" map into a web page. However, unlike these other services, Leaflet can be used to display maps from a whole range of map providers, including, but not restricted to, OpenStreetMap.

Latitude and Longitude

In order to understand location-based applications, it is important to understand the coordinate system used on the earth. The most common coordinate system uses latitude and longitude. Latitude is a measure of how far north or south you are: the equator is at 0 degrees, while the North Pole is at 90 degrees North, we are at about 50 and Spain is at about 40. Longitude is a measure of how far east or west you are: 0 degrees of longitude is referred to as the Prime Meridian (or Greenwich Meridian) and passes through Greenwich, London. By contrast Germany is located between approximately 7 degrees and 15 degrees East, while New York is at 74 degrees West and the west coast of North America at approximately 120 degrees West.

Latitude and longitude

So a given point on the earth can be defined via its latitude and longitude. We are at, approximately, 50.9 North (latitude) and 1.4 West (longitude). By convention, latitudes north of the equator and longitudes east of Greenwich are treated as positive, so we can also define our position as longitude -1.4, latitude +50.9.

Projections

An important consideration when doing web mapping is that the earth is not flat (it's more or less a sphere) while maps are flat. To display a curved surface on a flat piece of paper or computer screen, we need to do a projection and mathematically transform the latitude and longitude to coordinates suitable for representation on a flat surface. Why is this? Imagine any printed map of the earth. The map is equal width everywhere, from far northern areas such as Greenland or north Norway, to the equator. This does not match reality; since the earth is (more or less) a sphere, the circumference of the earth will be much greater at the equator than those far northern areas - indeed, at the poles, the circumference of the earth is zero!

For this reason, latitude and longitude must be transformed to so called projected coordinates if we want to represent them on a flat surface, such as a computer screen. The details of exactly how this projection is done is out of scope of this unit, but it is something to be aware of if you aim to do more with web mapping. Leaflet makes it easy for us by doing the transformation automatically.

The most common projection used with web mapping is informally referred to as the "Google Projection" (more formally, a type of Spherical Mercator), so called because Google Maps popularised it.

Projecting sphere onto flat surface

Details on the "Google Projection"

If you are interested, this is how the "Google Projection" works. It consists of a series of zoom levels, with 0 the most zoomed out and successive levels progressively zoomed in. How does this work? Basically, zoom level 0 is defined as a flat map of the entire world, occupying 256x256 pixels, so that 360 degrees of longitude becomes 256 pixels and 180 degrees of latitude becomes 256 pixels, as shown below:
Google Projection zoom level 0
Each successive zoom level zooms in by a factor of 2 in both directions, so that at zoom level 1, there are four 256x256 pixel tiles, each covering a quarter of the earth (N of the equator and W of the Prime Meridian; N of the equator and E of the Prime Meridian; S of the equator and W of the Prime Meridian and S of the equator and E of the Prime Meridian):
Google Projection zoom level 1
With progressive zoom levels, we continue zooming in by a factor of 2, so that zoom level 2 has 16 tiles (4x4), zoom level 3 has 64 (8x8), and so on. Each tile has an x and y coordinate where x=0 represents the leftmost column of tiles, y=0 represents the topmost row of tiles, and the tile with x=0 y=0 represents the top left tile (x=1 y=0 represents the second tile on the top row, and so on)

Images from OpenStreetMap, (c) OSM contributors, licenced under CC-by-SA (not ODBL as they are old images)

Before looking at Leaflet, we need to take a look at JavaScript (ECMAScript 6) modules as they provide a way of using third-party libraries such as Leaflet from client-side JavaScript code.

ECMAScript 6 Modules

So far, we have written our JavaScript in one file. That is fine when the application is small, but if you need to develop a larger, more complex application, the file will quickly become hard to follow, and thus hard to maintain. We can make our code more maintainable by splitting our routes into groups, all dealing with the same type of entity (for example, songs or users). You can achieve this by creating a separate router for each related group of routes, in a separate file called a module, which can be imported from your main server file.

There are two approaches for implementing modules, CommonJS modules and ECMAScript 6 modules. ECMAScript 6 modules are a web standard, and are increasingly well supported, so we will use ECMAScript 6 modules.

An example ECMAScript 6 module

The whole idea of modules is to write reusable code that can be imported into any project. Here is an example of a simple module. You'll notice that most of it is simple JavaScript. Only the export statement at the end makes it a module. Modules can be used in both Node.js and client-side, in-browser, JavaScript.

Note that the standard for ECMAScript 6 modules is to use the .mjs extension (JavaScript module) rather than .js. You must use this convention from Node.js, and it is optional in browsers.


// Module mymaths.mjs

function square(n) {
    return n * n;
}

function cube(n) {
    return n * n * n;
}

export { square, cube };

Note how this module contains two functions, square() and cube(), which calculate the square and the cube of of a number, respectively. However the interesting thing is the statement at the end:

export { square, cube };

This statement exports the two functions, so that they can be used from the outside world. This file would be saved as a simple JavaScript file, e.g. mymaths.mjs.

Using the module from another file

We've created a simple module, but how might we use it from another file? We need to import the functions that have been exported. This could be done from your main server in a Node/Express example, or from browser-based JavaScript. Here's an example of using the module from the main file of a client-side JavaScript application. This is often named index.mjs by convention.

// index.mjs - 'main' JavaScript file

// Import the two functions from the module. 
// Note the './' before 'mymaths.mjs'. This means 'the current folder'
import { square, cube } from './mymaths.mjs';

const a = square(3);
console.log(`The square of 3 is : ${a}`);

const b = cube(2);
console.log(`The cube of 2 is : ${b}`);

Note how we have to import the functions from the module before we can use them. This makes for easier-to-read code as we can tell exactly where the two functions cube() and square() have come from.

Including modules into a web page

To actually make this code run in a browser, we need to link it to an HTML web page which can be executed from the browser.

In a browser, we link a module in the same way as a regular JavaScript file, except we use <script type='module'>. Note that the main JavaScript file, index.mjs, is itself a module. It's the 'main module' of the application and it's using another module, mymaths.mjs. We have actually seen this already, in week 3. For example:

<html>
<head>
<script type='module' src='index.mjs'></script>
</head>
<body>
...

A critical point is that you have to access your site via a web server to use modules. You can use your own Express server, or if you want to test quickly, you can easily set up a web server in the current folder with:

npx http-server
and then access pages on port 8080, e.g:
http://localhost:8080/index.html

Example 2: Only importing some functions

It's possible to only import some functions from a module. For instance this version of our index.mjs code:

// index.mjs - 'main' JavaScript file

// Import cube() (only) from the module. 
import { cube } from './mymaths.mjs';

// Call the cube() function from the module
const a = cube(3);
console.log(`The cube of 3 is : ${a}`);

// This will not work now, as square() was not imported from the module
const b = square(4);
console.log(`The square of 4 is : ${b}`);

Example 3: Grouping all members of an exported module into a module object

It can be a little messy to import each function separately. It would be nicer if we could collect together all module exports as a single object when we import them. This approach is frequently used with libraries. This version of index.mjs shows this:

// index.mjs - 'main' JavaScript file

// Import the two functions from the module into a module object 'MyModule'
import * as MyModule from './mymaths.mjs';

// Call the functions from the module

const a = MyModule.square(3);
console.log(`The square of 3 is : ${a}`);
const b = MyModule.cube(4);
console.log(`The cube of 4 is : ${b}`);

Note how this differs from the first two examples. First, consider the import statement:

import * as MyModule from './mymaths.mjs';

Rather than importing each function separately, they are all being imported as a single module object, MyModule. You can reference each exported function by referencing the module object name, then a dot, then the function name, for example MyModule.cube(...).

(To relate this to object-oriented programming, which we did in your web module last year and you may have done COM528, MyModule is an object and cube() is a method of that object).

Example 4: Default export

If your module only needs to export one function or object, you can declare this as the default export. This is another way of achieving the effect of the previous example, with all exports from a module packaged into a single object. However, with a default export, we group everything together as an object when exporting, rather than when importing. So here is an example of the module using a default export:

function _cube(name) {
    return n*n*n; 
}

function _square(n) {
    return n*n;
}

const exportedObject = {
    cube: _cube,
    square: _square,
    PI: 3.141592654
};

export default exportedObject;

This exports a default object with two fields: The cube field is set equal to the function _cube() (note that an underscore is a convention in JavaScript for a 'private' or 'internal function), and the square field is set equal to the function _square().

We can then use the default export in our main module using:

import MyDefaultObject from './mymaths.mjs';

This will import the default export from the module as MyDefaultObject, so we can then call the methods with:

const a = MyDefaultObject.cube(4);
const b = MyDefaultObject.square(9);
and we can also use the constant PI, as that is included in the exported object:
console.log(MyDefaultObject.PI);

What about npm modules?

You've already been introduced to npm modules as the standard approach to add dependencies (third-party libraries) to a Node project. npm modules also exist for the client-side, in other words you can incorporate them into client-side JavaScript code. So you can install them with npm, e.g.:

npm install leaflet
and specify them in your package.json, e.g:
{
    "dependencies": {
        "leaflet": "1.9.4"
    }
}
However you cannot simply use code such as :
import "leaflet";
to import Leaflet, for example. The browser is unable to resolve npm packages in this way. Instead, you must use third-party tools called bundlers to be able to use client-side npm packages in a working application.

Bundlers

So what do we do? We cannot use package names in our imports, because they are only supported by Node.js, not in browsers. This is where bundlers come to the rescue.

A bundler is a piece of software which will take many source JavaScript files (your own files and imports from npm modules) and generate a single bundle (typically named bundle.js) as the output. The bundle can then be included into a web browser in the normal way (without type=module).

One of these tools is Webpack. Webpack can be installed with npm; there are two packages you need, webpack and webpack-cli. As Webpack is a development tool, you want to install it globally, so that it is accessible to all applications on your system and all users. The -g option does this. (Normally, npm packages are only installed locally into the project that needs them, within the project's node_modules folder).

npm install -g webpack webpack-cli

It's better, though, to incorporate them into your package.json, e.g here is a package.json which specifies Leaflet as a dependency and Webpack as a dev dependency:

{
    "dependencies": { 
        "leaflet": "1.9.4",
    },
    "devDependencies": {
        "webpack": "5.90.1",
        "webpack-cli": "5.1.4"
    }
}
Note that the two components of Webpack, webpack and webpack-cli, are specified as devDependencies. This means that they are only needed for development, not the application itself.

Here is the most simple usage:

npx webpack index.js

This will take the file index.js, and any modules it uses (along with any modules used by those modules) and prepare a single output file - a bundle - which will, by default, be placed in the dist subfolder. This can then be used directly in the browser, e.g if the file is bundle.js you would use:

<script type='text/javascript' src='dist/bundle.js' defer></script>

(Note the defer prevents the bundle loading until the page has been loaded. If you do not do this, the bundle will be loaded before the page, which means if you try to access HTML elements from your JavaScript, you will get errors relating to null elements.)

One key advantage of using a bundler like Webpack is that the bundler automatically includes third-party npm packages in the bundle, if they are imported in your code using the package name e.g.:

import 'leaflet';

which will import Leaflet.

Webpack configuration

Due to time constraints, we will only take a brief look at Webpack configuration, enough to build a project making use of Leaflet. By default, Webpack will place its output in the file main.js within the dist folder, and it will also minify the code, in other words convert it into an unreadable but compact form for distribution.

However it is possible to configure Webpack to change this behaviour, and this is done via the webpack.config.js configuration file. This is a file, itself written in JavaScript, which allows you to modify Webpack's default behaviour. Here is an example:

const path = require('path');

module.exports = {
    mode: 'development',
    entry: './public/index.js',
    output: {
        path: path.resolve(__dirname, 'public/dist'),
        filename: 'bundle.js'
    },
    optimization: {
        minimize: false
    }
};

What is this doing?

npx webpack

rather than

npx webpack ./index.js

because the configuration file has told Webpack that the entry point of the application will be ./index.js.

There are many other things you can do with Webpack, for example you can include CSS inside your bundle for easy distribution, as discussed below. See the website for more detail.

Including CSS in Webpack

You can even use Webpack to include CSS in the bundle, and import the CSS from your JavaScript. To do this, you must add two new dependencies: style-loader and css-loader. Both are available as npm modules. What do these do?

These two modules are Webpack addons known as loaders.

The highlighted code below, when added to webpack.config.js, tells Webpack to use these two modules when processing the input JavaScript.

const path = require('path');

module.exports = {
    mode: 'development',
    entry: './public/index.js',
    output: {
        path: path.resolve(__dirname, 'public/dist'),
        filename: 'bundle.js'
    },
    optimization: {
        minimize: false
    },
    module: {
        rules: [
            // If it's CSS, process using CSS loaders
            { test: /\.css/i, use: ['style-loader', 'css-loader'] },
        ]
    },
};
What the highlighted code is doing is specifying rules for Webpack. If the filename ends with .css, use the modules css-loader and style-loader to load the CSS, inject it into your code, and include it in the bundle.

Leaflet coding examples

Having looked at modules, we can now examine some Leaflet example code. We will be using OpenStreetMap as the mapping provider.

Hello World in Leaflet

This is a basic example which creates a map. First the JavaScript:

import 'leaflet';

const map = L.map ("map1");

const attrib="Map data copyright OpenStreetMap contributors, Open Database Licence";

L.tileLayer
        ("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
            { attribution: attrib } ).addTo(map);
            
map.setView([50.908,-1.4], 14);
Then the HTML:
<html>
<head>
<title>Leaflet Example</title>
<script type='text/javascript' src='dist/bundle.js' defer></script>
<link rel='stylesheet' href='https://unpkg.com/leaflet@1.9.4/dist/leaflet.css' />
<body>
<h1>Leaflet Test</h1>
<div id="map1" style="width:800px; height:600px"> </div>
</body>
</html>

Note the following:

Adding features

Most web maps have some kind of overlay on the base map, for example a series of markers plotting the locations of pubs, cafes or other points of interest. We can even draw vector shapes (lines, polygons, circles) and add them to the map. This example creates a map and adds a feature to it:

import 'leaflet';

const map = L.map ("map1");

const attrib="Map data copyright OpenStreetMap contributors, Open Database Licence";

L.tileLayer
        ("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
            { attribution: attrib } ).addTo(map);

const pos = [50.908, -1.4];            
map.setView(pos, 14);

L.marker(pos).addTo(map);

Hopefully this code is obvious. We simply create a marker at the specified position (L.marker takes an array of two members, latitude and longitude), and add it to the map.

Events

In a mapping application, we commonly need to respond to user events, for instance we might want something to happen if the user clicks on the map (such a display a new marker, for instance) or if the user finishes dragging the map to a new location (we might want to load markers from a server, for instance). It is easy to attach events in Leaflet, here is an example:

import 'leaflet';

const map = L.map ("map1");

const attrib="Map data copyright OpenStreetMap contributors, Open Database Licence";

L.tileLayer
        ("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
            { attribution: attrib } ).addTo(map);
            
map.setView([50.908, -1.4], 14);
map.on("click", e => {
    // "e.latlng" is an object (of type L.LatLng) representing the mouse click 
    // position
    // It has two properties, "lat" is the latitude and "lng" is the longitude.
    alert(`You clicked at:${e.latlng.lat} ${e.latlng.lng}`);
});


We use the on() method of the map to attach an event handler to the map. The on() method takes two parameters: the event type and the event handler function. A full list of event types can be found on the Leaflet website.

In the event-handling function itself (an arrow function), we use the event object e to obtain details about the event (in this case, we are interested in the click position). The event object e is automatically passed to the event handler function by the Leaflet library. The event object has a latlng property, representing the position of the mouse click, which is an object of the type L.LatLng. This in turn has two properties, lat and lng representing the actual latitude and longitude.

Popups

One commonly-encountered feature of web mapping is popups, in which the user can click on a marker and be presented with additional information on that feature. These are easy to do in Leaflet: we simply call the bindPopup() method of the feature to attach a popup to that feature. bindPopup() takes one parameter, the text (you can include HTML tags) to appear in the popup. Here is an example. Note how you have to store the marker in a variable so that you can then call bindPopup():

import 'leaflet';

const map = L.map ("map1");

const attrib="Map data copyright OpenStreetMap contributors, Open Database Licence";

L.tileLayer
        ("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
            { attribution: attrib } ).addTo(map);

const pos = [50.908, -1.4];            
map.setView(pos, 14);

const marker = L.marker(pos).addTo(map);
marker.bindPopup("My Location");

Further reading

Please see here for further reading on mapping, including polygons and lines and styling features.

Exercises

IMPORTANT! Please add your mapping code (HTML, JavaScript) to the public folder of your existing Express application and access it via Express, e.g.:

http://localhost:3000/mapapp.html
This is because JavaScript modules can only be accessed if a server is running.

Exercise 1

  1. Create a package.json with dependencies. Place this in the top-level folder of your project. Include the following dependencies: and the following dev dependencies: Install all dependencies with:
    npm install
  2. Write a webpack.config.js, which is similar to the example above.
  3. New York is at longitude 74 West, latitude 40.75 North (more or less). Change the example above so that it's centred on New York, at zoom level 13. Build a bundle using Webpack.
  4. Find the latitude and longitude of your home town (e.g. google it) and change the example so it's centred on your home town.

Exercise 2

  1. Add a marker on your map from Exercise 1 on your home town.
  2. Combine the marker and mouse click event examples, above, so that by clicking on the map, you add a marker to the map at that position.
  3. Using a prompt box to read the text in, bind a popup to the marker containing text entered by the user. Use a prompt box to read information in from the user, e.g:
    const text = prompt('Please enter some text');

Exercise 3 - Connecting to a web API and displaying markers

The database you've been working with contains a table called artists which stores the latitude and longitude of the home towns of selected artists, in addition to the wadsongs.

Exercise 4 - adding data to a web API via a map interface