Developing an OpenLayers app from scratch in ES6 using Mocha, Webpack and Karma: Projections
… 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:
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:
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.
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:
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:
Nice! This is what we want to see
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.
Support
If you liked this post and want to see more like this, please buy me a coffee!
