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.
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.
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.
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.
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.
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:
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):
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.
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.
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
.
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.
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-serverand then access pages on port 8080, e.g:
http://localhost:8080/index.html
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}`);
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).
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);
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 leafletand 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.
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.
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?
mode
can be either development
or production
. This impacts upon the amount of minifying that takes place (less in development, more in production; debugging is easier with an unminified bundle)./public/index.js
(index.js
in the public folder within the current folder).
This means you can run Webpack withnpx 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
.
path
is the folder where the output bundle will be placed. Here, we are specifying the output is the dist
subfolder of the public
folder within the current folder, and the filename is bundle.js
. So, the resulting bundle will be named bundle.js
. If you do not specify the bundle name, it will use the default name, which is main.js
. But in fact, bundle.js is a better name as it more clearly states that it's a bundle.optimization
options allow us to specify various ways in which the output will be optimised. Here, we are turning minification - the process of compressing JavaScript code to less readable but smaller code by renaming all variables and functions to short names and removing spaces - off by setting the minimize
option to false
. This is not ideal for a production app but is very useful for debugging, as you will not get helpful error messages with a minified bundle file.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.
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?
css-loader
interprets import
statements referencing CSS files so that the CSS files are read;style-loader
then "injects" the CSS code read from css-loader
into your code, and thus into the bundle.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.
Having looked at modules, we can now examine some Leaflet example code. We will be using OpenStreetMap as the mapping provider.
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:
import
statement and include it via Webpack, due to incompatibility issues between Leaflet and Webpack.import
to link in Leaflet.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.
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.
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");
Please see here for further reading on mapping, including polygons and lines and styling features.
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.htmlThis is because JavaScript modules can only be accessed if a server is running.
package.json
with dependencies. Place this in the top-level folder of your project. Include the following dependencies:
express
, at least version 4.18.2;better-sqlite3
, at least version 9.4.1;leaflet
, at least version 1.9.4.webpack
, at least version 5.90.1;webpack-cli
, at least version 5.1.4;css-loader
, at least version 6.10.0;style-loader
, at least version 3.3.4.npm install
webpack.config.js
, which is similar to the example above.const text = prompt('Please enter some text');
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
.
/hometown/:artist
which looks up the hometown of a particular artist. It should return
a JSON object containing the hometown name, latitude and longitude, or a 404 if the requested artist cannot be found in the artists
table.const artist = prompt('Please enter an artist name');