Developing an OpenLayers app from scratch in ES6 using Mocha, Webpack and Karma: Projections

18 minute read

… where we investigate projections and view our map from an Arctic perspective.

Projections: different ways of looking at the world

In the last section we met the need to convert from longitude/latitude coordinates to some kind of what I’ll very loosely call “map units”. This was necessary because we needed to translate from units that humans are most familiar with (degrees longitude/latitude) to units that make sense on a 2D map. This is an important point: a map is a 2D representation of a 3D object (the earth).

It turns out that there are many ways of representing our 3D world on a 2D surface, each with various advantages and disadvantages. To create a 2D map of the world, it’s necessary to translate every 3D location on the surface of the globe to a given location on the map. This process is called projection; the different 2D representations of the globe are called projections, because they are generated by projecting a point on the globe to a point in some 2D space.

One of the most popular projections is Mercator, in particular because it makes navigation easier, but also because the surface of the earth is mapped to a Cartesian grid and thus lines of constant latitude are parallel with the x-axis and lines of constant longitude are parallel with the y-axis. Another way of putting this is that north is always “up” and south is always “down”; west is always “left” and east is always “right”. This projection also uses metres as its unit, so that one can more easily measure distances in familiar units (rather than in, say, degrees latitude or longitude). These features of the Mercator projection make navigation in a band around the equator much simpler and it is fairly intuituive, however it means that the map is very distorted close to the poles: distances of a few metres in reality are “stretched” to several kilometres.

So if we’re interested in a map of, say, the Arctic, what do we do? We use a Stereographic Projection, in particular one of the many Polar Stereographic Projections. In these cases, we have a map where the distortion at the pole is minimised, with the disadvantage of large distortions at the equator (but hey, we’re not interested in the equator, so we can live with the distortion). A commonly used projection for the Arctic is NSIDC Sea Ice Polar Stereographic North, which we’ll migrate to slowly using our test suite as a guide.

Changing our focus to the Arctic

Let’s choose a location in the Arctic upon which we can centre our map. A good candidate is Longyearbyen which is the largest settlement in the Svalbard archipeligo, is located north of the Arctic circle and is home to the world’s northern-most university. It’s located at 78.217 N, 15.633 E, so let’s update our test for the map centre to expect to be here rather than in Prague; our test will now look like this:

describe('Basic map', function () {
    it('should have a view centred on Longyearbyen', function () {
        let lon, lat;
        [lon, lat] = toLonLat(map.getView().getCenter());
        lon.should.be.closeTo(15.633, 1e-6);
        lat.should.be.closeTo(78.217, 1e-6);
    });
});

Running the tests (make test) gives this output:

  Basic map
    1) should have a view centred on Longyearbyen


  0 passing (5ms)
  1 failing

  1) Basic map
       should have a view centred on Longyearbyen:
     AssertionError: expected 14.439999999999998 to be close to 15.533 +/- 0.000001
      at Context.<anonymous> (test/map-test.js:10:23)
      at processImmediate (internal/timers.js:456:21)
      at process.topLevelDomainCallback (domain.js:137:15)

We expect this failure, since we haven’t updated the production code to centre on Longyearbyen yet.

Update the center attribute of the view to match the coordinates for Longyearbyen in src/js/index.js and re-run the tests. You should see that the tests pass again.

  Basic map
    ✓ should have a view centred on Longyearbyen


  1 passing (5ms)

Now run make build to create the webpack bundle and reload the map in your web browser. You should see something very similar to this:

Map centred on Longyearbyen

We’re now much more confident that our code is doing what we intend it to do. This is a good point to commit the changes to the repository:

# mention what the changes were and why in the commit message
$ git commit src/js/index.js test/map-test.js

Refactor time!

We plan to have multiple layers in this application: one for the map and one for the image data. Adding the next layer in the layers array is going to make the code hard to understand, so let’s extract the OpenStreetmap TileLayer into its own variable:

const osmLayer = new TileLayer({
    source: new OSM()
});

const map = new Map({
    target: 'map',
    layers: [osmLayer],
    view: new View({
        center: fromLonLat([15.633, 78.217]),
        zoom: 4
    })
});

Re-running the tests shows that we haven’t broken anything by making this change. Let’s also extract the View into its own variable:

const osmLayer = new TileLayer({
    source: new OSM()
});

const arcticView = new View({
    center: fromLonLat([15.633, 78.217]),
    zoom: 4,
});

const map = new Map({
    target: 'map',
    layers: [osmLayer],
    view: arcticView,
});

Again, running the test suite shows that we haven’t broken anything. Note that I’ve jumped the gun here a bit by calling calling the View variable arcticView because we aren’t yet using an Arctic projection, but that’s not such a bad transgression as we’re heading in that direction anyway.

Now commit the changes:

# mention extraction of view and layer into own variables
$ git commit src/js/index.js

Hrm, I’m not happy with the map’s name. Do you think your users will want the map to be called “My Map”? No. Let’s make this more descriptive by calling it “Arctic Sea-Ice Concentration”. Also, notice that the text in the title bar is “OpenLayers example”. Users will probably think this is a bit dodgy, so it too needs a better name; how about “Arctic Ice Data”? These changes require the <h1> and <title> elements (respectively) to be set correctly. Opening src/index.html in an editor, we can fix the title easily:

    <title>Arctic Ice Data</title>

however, we notice that the “My Map” header is actually an <h2> element even though there’s no preceding <h1> element. That was naughty of us! Thus we need to change the tag name and its contents from

    <h2>My Map</h2>

to

    <h1>Arctic Sea-Ice Concentration</h1>

Now build the app with make build and reload it in your browser. It should look something like this:

Map title and header now match app's purpose and content

That’s much better! Commit the changes so that they don’t get away from us:

# mention update of app title and main header in commit message
$ git commit src/index.html

View from the top of the world

To let OpenLayers know that we want to use the NSIDC Sea Ice Polar Stereographic North projection, we add the projection option to the View constructor and pass in a Projection object or by a string specifying the projection’s EPSG code1. By default, OpenLayers uses Spherical Mercator, also known as Pseudo Mercator or Web Mercator, because it is commonly used for web-based maps.

OpenLayers has several projections that it already knows about, unfortunately, epsg:3413 isn’t one of them. You can test this by adding the projection option to the View constructor:

const arcticView = new View({
    center: fromLonLat([15.633, 78.217]),
    zoom: 4,
    projection: 'EPSG:3413',
});

The test suite should fail with an error message like this:

/path/to/arctic-sea-ice-map/node_modules/ol/View.js:1
TypeError: Cannot read property 'getExtent' of null
    at createResolutionConstraint
(/path/to/arctic-sea-ice-map/node_modules/ol/View.js:1509:33)
    at View.require.View.applyOptions_
(/path/to/arctic-sea-ice-map/node_modules/ol/View.js:338:40)
    at new View
(/path/to/arctic-sea-ice-map/node_modules/ol/View.js:326:15)

The error Cannot read property 'getExtent' of null means that the view can’t get the extent of a null projection and hence the test fails. If you build the project (make build) and reload the app in your browser, you’ll see that the app stops working.

Remove this change with

$ git checkout src/js/index.js

so that we get back to a clean state before going further.

The solution to the problem of the missing projection definition is to define the projection ourselves, and to do this we need to use the proj4js library. Install the library via npm:

$ npm install proj4

Now we need to import the proj4 library into our app as well as the register function from the OpenLayers proj4 library, so that we can tell OpenLayers about any new projections we define. Add these lines to the list of import statements in src/js/index.js:

import proj4 from 'proj4';
import {register} from 'ol/proj/proj4';

Now we can define the ‘EPSG:3413’ projection using its PROJ4 definition (see, e.g. the proj4js definition for the projection on epsg.io):

proj4.defs(
    'EPSG:3413',
    '+proj=stere +lat_0=90 +lat_ts=70 +lon_0=-45 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs',
);

(this code can come after the import statements). Now we just need to let OpenLayers know about this new PROJ4 definition:

// ensure OL knows about the PROJ4 definitions
register(proj4);

This time, if we add the line

    projection: 'EPSG:3413',

to the View constructor, we’ll find that the tests pass.

However, if we build the project via make build and view the app in dist/index.html we’ll find that the map is centred over Australia and Indonesia and that these are shown upside down. What’s going on now? Remember how we were using fromLonLat and toLonLat to convert longitudes and latitudes into map-based (projection-based) coordinates? Well, these functions assume the OpenLayers default projection (EPSG:3857 a.k.a. Web Mercator) unless told otherwise. We thus need to explicitly specify the projection when calling these functions in order to correctly translate longitude/latitude values into the appropriate projection coordinates.

Let’s fix the test first. Add the argument 'EPSG:3413' to the toLonLat() call within our test:

    it('should have a view centred on Longyearbyen', function () {
        let lon, lat;
        [lon, lat] = toLonLat(map.getView().getCenter(), 'EPSG:3413');
        lon.should.be.closeTo(15.633, 1e-6);
        lat.should.be.closeTo(78.217, 1e-6);
    });

Running the test suite should now barf horribly (but then, we expect this to happen, so it’s not a bad thing).

  Basic map
    1) should have a view centred on Longyearbyen


  0 passing (9ms)
  1 failing

  1) Basic map
       should have a view centred on Longyearbyen:
     AssertionError: expected 128.14963323608137 to be close to 15.633 +/- 0.000001

A note for those who have been watching closely: we didn’t have to define 'EPSG:3413' within the test file because it got added to the global namespace when we imported the map variable from the main package (in index.js). This came as a bit of a surprise to me (especially as someone used to other languages); I’d expected that I’d have to define the projection in the test file because I’m not exporting it from the main package. However, it seems that JavaScript not only parses the code it imports, but executes it as well, so there can be side effects from using an import statement. This is good to know and good to keep in mind when developing JavaScript code that this can happen.

Returning to the main code, we now just need to add the projection to the fromLonLat() call:

    center: fromLonLat([15.633, 78.217], 'EPSG:3413'),

The test suite will now pass. Yay! Also, building the project and reloading the app in a browser will show that we’re now viewing the Arctic.

Arctic map centred on Longyearbyen

To check that we definitely are centred above Longyearbyen, zoom in using the + button in the top left-hand corner. You should see something like this:

Arctic map zoomed

which shows Svalbard and if you zoom in even further, you will find that it is, indeed, centred over Longyearbyen.

That’s a nice result, so let’s commit this state to the repository:

# mention that we're using the new projection in the commit message and why
$ git commit package-lock.json package.json src/ test/

Remove projection name duplication

Have you noticed that we’ve referenced the string 'EPSG:3413' several times? Not only is it fairly hard to type, it’s not good programming style to directly refer to a string in several places; we should be using a variable so that we can test aspects of the projection definition, but also so that we can avoid typos in the string and hence avoid having incorrectly defined projections being used in functions and constructors.

Let’s define the projection as a variable. To do this, we need to import the get function from the ol/proj library:

import {get} from 'ol/proj';

then, after the proj4 definitions have been registered, define the projection as a variable:

const epsg3413 = get('EPSG:3413');

Now we can replace the projection strings with this variable; the View constructor looks thus:

const arcticView = new View({
    center: fromLonLat([15.633, 78.217], epsg3413),
    zoom: 4,
    projection: epsg3413,
});

Running the test suite, you should see the tests pass. However, if we make the same replacement in the test:

    it('should have a view centred on Longyearbyen', function () {
        let lon, lat;
        [lon, lat] = toLonLat(map.getView().getCenter(), epsg3413);
        lon.should.be.closeTo(15.633, 1e-6);
        lat.should.be.closeTo(78.217, 1e-6);
    });

the tests will barf:

  Basic map
    1) should have a view centred on Longyearbyen


  0 passing (9ms)
  1 failing

  1) Basic map
       should have a view centred on Longyearbyen:
     ReferenceError: epsg3413 is not defined
      at Context.<anonymous> (test/map-test.js:12:65)
      at process.topLevelDomainCallback (domain.js:120:23)

which is not a bad thing, because we’re no longer referencing (via a string) a projection which has been implicitly made available in the global application namespace. What we can do now is extract the projection definition into a module and we can import only the things that we need from it: in this case, the projection object.

Let’s take a step back and undo the change we made to the test file, so that we can get the tests passing again:

$ git checkout test/map-test.js

Now the tests pass and we have a solid base to work from.

Our plan now is to extract the projection-related code into its own module. We’ll start by creating a test file for the projections module we want to build. Open the new file test/projections-test.js in your favourite editor, import the chai library and add the test suite description:

import 'chai/register-should';

describe('EPSG:3413', function () {
});

One of the simplest tests we can write would be to check if the projection’s EPSG code matches that which we expect: ‘EPSG:3413’. This will bootstrap the test suite for the projections module. Add the following test to the describe block:

    it('should use EPSG:3413 as its projection code', function () {
        epsg3413.getCode().should.equal('EPSG:3413');
    });

The tests now die with this error message:

  Basic map
    ✓ should have a view centred on Longyearbyen

  EPSG:3413
    1) should use EPSG:3413 as its projection code


  1 passing (16ms)
  1 failing

  1) EPSG:3413
       should use EPSG:3413 as its projection code:
     ReferenceError: epsg3413 is not defined
      at Context.<anonymous> (test/projections-test.js:5:5)
      at process.topLevelDomainCallback (domain.js:120:23)

The important thing to note is that the variable epsg3413 hasn’t been defined. That’s something we should import from the projections module we want to build, so let’s import that into the test file:

import epsg3413 from '../src/js/projections.js';

Of course the test suite dies horribly:

/path/to/arctic-sea-ice-map/test/projections-test.js:1
Error: Cannot find module '../src/js/projections.js'
Require stack:
- /path/to/arctic-sea-ice-map/test/projections-test.js

because it can’t find the file we reference, so let’s create that. Open the file src/js/projections.js in your editor and add the following code:

export { epsg3413 as default };

The tests still fail (we didn’t expect otherwise) but they get a bit further (which we did expect):

file:///path/to/arctic-sea-ice-map/src/js/projections.js:1
export { epsg3413 as default };
         ^^^^^^^^

SyntaxError: Export 'epsg3413' is not defined in module

Now move the lines

import proj4 from 'proj4';
import {register} from 'ol/proj/proj4';
import {get} from 'ol/proj';

proj4.defs(
    'EPSG:3413',
    '+proj=stere +lat_0=90 +lat_ts=70 +lon_0=-45 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs',
);

// ensure OL knows about the PROJ4 definitions
register(proj4);

from src/js/index.js into src/js/projections.js (make sure to put this code above the export statement that is already in the file). The tests will still die; this time because index.js doesn’t know anything about the variable epsg3413.

/path/to/arctic-sea-ice-map/src/js/index.js:1
ReferenceError: epsg3413 is not defined

Let’s import it to get rid of the error. Add the line

import epsg3413 from './projections.js';

to the end of the list of imports in src/js/index.js. Now the tests pass!

  Basic map
    ✓ should have a view centred on Longyearbyen

  EPSG:3413
    ✓ should use EPSG:3413 as its projection code


  2 passing (8ms)

Now we can use our new module in the map tests. Add the following line after map has been imported into test/map-test.js:

import epsg3413 from '../src/js/projections.js';

and replace 'EPSG:3413' with the variable epsg3413. The tests still pass! Great!

This is a good place to commit our progress to the repository:

$ git add src/js/projections.js test/projections-test.js
# mention extraction of projection into module and reduction of code
# duplication in the commit message
$ git commit src/ test/

Focussing more on the Arctic

Did you notice that the zoomed-out view of the Arctic (mentioned above) showed quite a lot of the globe? It even showed parts of Europe, Africa, the Middle East and North America which won’t ever have sea-ice. Let’s be more focussed on the Arctic and limit the map view to a more specific area. Since this is an attribute of the projection object we’ll need to extend the tests for the projections module. Open test/projections-test.js and add the following test to the main describe block:

    it('should use the NSIDC north pole max sea ice extent', function () {
        const expectedExtent = [-5984962.406015, -6015037.593985, 6015037.593985, 5984962.406015];
        epsg3413.getWorldExtent().should.be.deep.equal(expectedExtent);
        epsg3413.getExtent().should.be.deep.equal(expectedExtent);
    });

where the extent is defined by the extremal lower-left and upper-right coordinates in the NSIDC Sea Ice Polar Stereographic North projection.

As expected, the tests fail because we haven’t yet defined the extent for the projection:

  Basic map
    ✓ should have a view centred on Longyearbyen

  EPSG:3413
    ✓ should use EPSG:3413 as its projection code
    1) should use the NSIDC north pole extent


  2 passing (11ms)
  1 failing

  1) EPSG:3413
       should use the NSIDC north pole extent:
     TypeError: Cannot read property 'should' of null

The error message Cannot read property 'should' of null tells us that the output of getWorldExtent() in the test returned null. If we now set the map and world extents in the projections module:

const fullMapExtent = [-5984962.406015, -6015037.593985, 6015037.593985, 5984962.406015];
const epsg3413 = get('EPSG:3413');
epsg3413.setWorldExtent(fullMapExtent);
epsg3413.setExtent(fullMapExtent);

we find that the tests pass. Cool! To restrict the map view to the newly-defined extent, we pass the extent parameter to the View constructor.

    extent: epsg3413.getExtent(),

The extent that OpenLayers uses for the view depends upon the zoom level and the resolution at which the map should be displayed, hence it’s not sensible to test for the view’s extent because this number isn’t a constant. However, to see that the map behaves as we intend, build the project (make build), load the app in a browser and zoom out to the minimum zoom level. Panning around you can see that the maximum extent for the map view is now much smaller than it was before.

This is a good place to save the state of the project to the repository:

# mention that we're setting the projection and view extents explicitly (and
# why) in the commit message
$ git commit src/ test/

Becoming more specific to Svalbard

Let’s make the map more specific to Svalbard by increasing the zoom level and rotating the map so that Svalbard has it’s typical “inverted triangle” shape which we are most used to when viewing Svalbard on e.g. a Mercator projection. We thus want to increase the zoom level to 6 and rotate the map by 45 degrees. We can put these requirements into tests and thus ensure that they are met.

First, let’s set the zoom level. Add this test to the map-test.js file:

    it('should have a default zoom of 6', function () {
        const zoomLevel = map.getView().getZoom();
        zoomLevel.should.equal(6);
    });

The tests will fail because we currently have a zoom level of 4. Set this value to 6 and run the tests again:

    zoom: 6,

Yay, the tests are passing. Commit the change to the repo:

# mention setting default zoom level in commit message
$ git commit src/js/index.js test/map-test.js

Now let’s set the default rotation by adding this test to map-test.js:

    it('should have a default rotation of 45 degrees', function () {
        const zoomLevel = map.getView().getRotation();
        zoomLevel.should.equal(45 * Math.PI / 180);
    });

Note that angles have to be in radians, hence the factor of π/180.

Of course the tests fail:

  1) Basic map
       should have a default rotation of 45 degrees:

      AssertionError: expected 0 to equal 0.7853981633974483
      + expected - actual

      -0
      +0.7853981633974483

Now set the rotation property in the View constructor:

    rotation: 45 * Math.PI / 180,

Running the tests again shows that the test suite passes.

  Basic map
    ✓ should have a view centred on Longyearbyen
    ✓ should have a default zoom of 6
    ✓ should have a default rotation of 45 degrees

  EPSG:3413
    ✓ should use EPSG:3413 as its projection code
    ✓ should use the NSIDC north pole max sea ice extent


  5 passing (10ms)

Building the app (make build) and opening it in a browser, you should see output similar to this image:

Svalbard-focussed map

Nice! This is what we want to see :smiley:

Commit this change as well:

# mention that we're setting the default rotation in the commit message
$ git commit src/js/index.js test/map-test.js

Recap

We now have a better idea of what projections are and how we can use them to control how a map is visualised. We’ve also been able to nail down more details of the application and what we expect it to look like and behave by building a small tests and then getting them to work. Therefore, slowly, bit by bit, we’re building an application on a foundation which is becoming more firm with every test. This part also involved a lot of work; perhaps it’s time you went to fetch a cup of tea or coffee before we dive into visualising Arctic sea-ice concentration data.

  1. Many projections have what is called an EPSG code, which allows mapping programs to specify well known projections in a concise manner. For Web Mercator this is epsg:3857; NSIDC Sea Ice Polar Stereographic North is epsg:3413

Comments