Printing maps in the browser, a story
Tags: openlayers, javascript, pdf, inkmap
Published: 2021-03-30 12:54:41

<small>Photo by <a href="https://unsplash.com/@stevep4?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Stephen Monroe</a> on <a href="https://unsplash.com/@stevep4?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a></small>

# An introduction

Maps are common on the web, whether interactive (think Google Maps) or static images. There are times though, when someone might need to transfer such a map into the real world. Yes, you guessed it! That is called _printing_.

To make this article more engaging, let us paint the scene of an imaginary situation: you are planning a hiking trip through Switzerland, and you want to print a map of the trails in a specific region. Being able to measure distances on paper is key, obviously.

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q373za6ecpjyh3b9jfyk.png)

Armed with courage and determination, you set out to print your own hiking map! And because you are not afraid of challenges, you are going to build a very simple web application to help you do that.

# Some considerations on printed maps

Paper maps share a lot with their digital counterparts, and it would be tempting to just copy-paste or screenshot a map on the web and then simply put it in a word document, for instance. This approach works but has substantial limitations, and it all revolves around one thing...

## The infamous DPI

DPI stands for _dot per inch_. A _dot_ designates the smallest drawable unit, either ink (for printers) or pixel (for screens). A DPI value is a ratio that basically expresses _how many small dots can be drawn inside an inch_.

Higher means more detail, and it is generally assumed that a DPI value of 300 produces the best print quality you can expect. The DPI value of computer screens is usually much lower than 300 though, and there is no way to reliably know it beforehand.

As such, a copy-pasted image will inevitably look blurry once on paper. Besides, we will have no indication of scale.

## Specialized software is here to help

There are a few dedicated software for printing high-definition maps, such as [Mapfish Print](http://mapfish.github.io/mapfish-print-doc/#/overview) which functions as a backend API. Given a data source, a geographic position, a scale, a paper size and DPI, Mapfish Print will generate a full PDF document (including legends!) and send it back to you. All good!

In this article though, we will take a closer look at [inkmap](https://github.com/camptocamp/inkmap), a library that generates printable maps all _inside_ the browser, thus eliminating the need for remote APIs.

Now, let's get back on trail!

# An application for printing hike trails (if you remember what hiking means anyway)

Before starting to write any kind of code, we need a data source, in this case: hike trails. Luckily for us, the Swiss federal topographic agency freely publishes this data online: [Swisstopo tiled map services](https://www.geo.admin.ch/en/geo-services/geo-services/portrayal-services-web-mapping/web-map-tiling-services-wmts.html)

We have everything we need, let's create the application!

## General approach

We probably should not get too carried away and stick to a very simple concept: our application will only contain an interactive map and a "print" button. On the interactive map we will draw a rectangle representing the area that will be printed. And finally we will give the user the possibility to move and resize this area.

When the "print" button is clicked we will call **inkmap**'s `print` method and eventually generate a ready-to-print PDF document. Easy!

## First draft

I will not go into too much details regarding the application scaffolding, you can take a look at the final project [here](https://github.com/jahow/hiking-trails-print) if you need inspiration.

To sum things up, you will need to initialize your project with npm and install _Webpack and friends™_ in order to setup the app:

```bash
$ npm init
$ npm install --save-dev webpack webpack-cli webpack-dev-server babel-loader @babel/core @babel/preset-env style-loader css-loader
```

The webpack configuration that I used is [here](https://github.com/jahow/hiking-trails-print/blob/master/webpack.config.js).

> Note that I would have preferred to use [Parcel](https://parceljs.org/) for bundling as it is much easier to setup, but unfortunately this will fail because of the dependencies used in the project

Next, add [OpenLayers](https://openlayers.org/) as a runtime dependency:

```bash
$ npm install --save ol
```

We are then going create two files in the project directory:

#### `index.html`

```html
<!DOCTYPE html>
<html>
<head>
  <title>hiking trails map generator</title>
  <style>
      html {
          height: 100%;
      }
      body {
          height: 100%;
          margin: 0;
          background: #f6f6f6;
          display: flex;
          flex-direction: column;
          justify-content: center;
          align-items: center;
      }
      #map-root {
          width: 800px;
          height: 600px;
          margin-bottom: 20px;
          border-radius: 3px;
          border: 1px solid grey;
      }
  </style>
</head>
<body>
  <p>
    Use the map to select an area and click the button to print it.
  </p>
  <div id="map-root"></div>
  <button type="button" id="print-btn">Print</button>

  <!-- include the script at the end
       to make sure the page is loaded -->
  <script src="./app.js"></script>
</body>
</html>
```

#### `app.js`

```js
import { fromLonLat } from 'ol/proj';
import Map from 'ol/Map';
import View from 'ol/View';
import TileLayer from 'ol/layer/Tile';
import OSM from 'ol/source/OSM';

// imports the OL stylesheet for nicer buttons!
import 'ol/ol.css';

// compute the map center from longitude and latitude
const mapCenter = fromLonLat([8.32, 46.90]);

// a simple OpenStreetMap layer (for development purposes)
const osmLayer = new TileLayer({
  source: new OSM()
});

// create the interactive map
const map = new Map({
  target: 'map-root',
  view: new View({
    zoom: 7,
    center: mapCenter,
    constrainResolution: true
  }),
  layers: [osmLayer]
});
```

Now you should be able to run `webpack serve --open` and see your app magically appear in your browser!

<figure>
<p><img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/s8sz1y9h6ubi0q1jj181.png" alt="A map of switzerland" loading="lazy"></p>
<p><figcaption>It all happened so fast!</figcaption></p>
</figure>

## Behold! Interactivity.

Using the [OpenLayers API](https://openlayers.org/en/latest/apidoc/) we are going to add an object on the map, shaped as a rectangle that matches the aspect of DIN paper formats (you know, the [A-series](https://en.wikipedia.org/wiki/Paper_size#A_series)).

To make it easily modifiable we are going to use a wonderful extension library called [ol-ext](https://viglino.github.io/ol-ext/), and more specifically its [Transform interaction](https://viglino.github.io/ol-ext/examples/interaction/map.interaction.transform.html). To install it:

```bash
$ npm install --save ol-ext
```

Finally, we will bind an event handler on the "print" button to output the rectangle coordinates (in preparation for what's next).

#### `app.js`

```js
// add these at the top of the file
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import Feature from 'ol/Feature';
import { Polygon } from 'ol/geom';
import { always as conditionAlways } from 'ol/events/condition';
import TransformInteraction from 'ol-ext/interaction/Transform';

// ...

// our rectangle (width to height ratio is √2
// as per DIN paper formats)
const rectWidth = 100000;
const rectHeight = rectWidth / Math.sqrt(2);
const rectangle = new Feature({
  geometry: new Polygon([[
    [mapCenter[0] - rectWidth, mapCenter[1] + rectHeight],
    [mapCenter[0] + rectWidth, mapCenter[1] + rectHeight],
    [mapCenter[0] + rectWidth, mapCenter[1] - rectHeight],
    [mapCenter[0] - rectWidth, mapCenter[1] - rectHeight],
  ]])
});

// this vector layer will contain our rectangle
const vectorLayer = new VectorLayer({
  source: new VectorSource({
    features: [rectangle]
  })
});

// this will give the user the possibility to move the
// rectangle around and resize it by dragging its corners
const transform = new TransformInteraction({
  layers: vectorLayer,
  stretch: false,
  keepAspectRatio: conditionAlways,
  rotate: false
});

// create the interactive map
const map = new Map({
  // ...
  layers: [osmLayer, vectorLayer]
});

map.addInteraction(transform);

// bind the print button click handler
document.getElementById('print-btn')
  .addEventListener('click', () => {
    const rectangleCoords = JSON.stringify(
      rectangle.getGeometry().getCoordinates()
    );
    console.log(rectangleCoords);
  });
```

Great! If everything went right you should be able to move the rectangle around and, when clicking "Print", you should see the modified coordinates appear in the console.

<figure>
<p><img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vomb2indvqmpgqhd0d0z.png" alt="A map with a rectangle over it" loading="lazy"></p>
<p><figcaption>Did I just play with this for 10 minutes?</figcaption></p>
</figure>

Note that these coordinates are expressed in [Web Mercator](https://en.wikipedia.org/wiki/Web_Mercator_projection) projection and will have to be translated to latitude and longitude values later on.

Here comes the tricky part: printing what's _inside_ the rectangle.

## Mild mathematics coming up

Time to install our printing companion, **inkmap**:
```bash
$ npm install --save @camptocamp/inkmap
```

**inkmap** offers a simple API in the form of the [`print`](https://github.com/camptocamp/inkmap#api) function, which needs a JSON spec to do its job. The JSON spec will look like this:

```js
{
  "layers": [
    // a list of data sources
  ],
  "size": [
    // expected map size
  ],
  "center": [
    // map center as longitude, latitude
  ],
  "dpi": // ever heard about this one?
  "scale": // this is the scale denominator
  "projection": // the map projection to use
}
```

Let's create a new module for encapsulating the computations that will be needed to produce the spec. The module will expose a `printAndDownload` function which takes the rectangle geometry, triggers a print of the area and download the result automatically:

#### `print.js`

```js
import { toLonLat } from "ol/proj";
import { getDistance } from "ol/sphere";
import { downloadBlob, print } from "@camptocamp/inkmap";

// more details on layers configuration later on
const bgLayer = {
  // ...
};

const trailsLayer = {
  // ..
};

/**
 * Requests a print from inkmap, download the resulting image
 * @param {Polygon} rectangleGeometry
 */
export function printAndDownload(rectangleGeometry) {
  // first get the geometry center in longitude/latitude
  const geomExtent = rectangleGeometry.getExtent();
  const center = toLonLat(
    rectangleGeometry.getInteriorPoint().getCoordinates()
  );

  // let's target a final format of A4:
  // the map will be 277 x 170 millimeters
  const size = [277, 170, 'mm'];

  // now the hard part: compute the scale denominator, which
  // is the ratio between the rectangle size in real world units
  // and the final printed size in the same units;
  // to do this we measure the width of the rectangle in
  // meters and compare it to the desired paper size
  const lowerLeft = toLonLat([geomExtent[0], geomExtent[1]]);
  const lowerRight = toLonLat([geomExtent[2], geomExtent[1]]);
  const geomWidthMeters = getDistance(lowerLeft, lowerRight);
  // paper size is in mm so we need to multiply by 1000!
  const scale = geomWidthMeters * 1000 / size[0];

  // let's print!
  print({
    layers: [bgLayer, trailsLayer],
    dpi: 150,
    size,
    center,
    scale,
    projection: 'EPSG:2056',
    scaleBar: true,
    northArrow: true
  }).then(imageBlob =>
    downloadBlob(imageBlob, 'hiking-trails.png')
  );
}
```

See how we computed the `scale` parameter of the spec sent to **inkmap**? This parameter is actually the _scale denominator_, in other words the ratio between the _rectangle real world size_ (probably several hundred meters) and the _final printed size_ (an A4 paper).

Once we have computed the scale, the rest is easy work. But, wait, haven't we missed something? Ah, yes, the _layers_! I have omitted them in the previous listing, let's talk about them now.

## Configuring data sources

Swisstopo publish their geospatial data through several formats, including [WMTS (Web Map Tile Service)](https://en.wikipedia.org/wiki/Web_Map_Tile_Service). This format is not very easy to work with but it allows us to query the data in a proper Swiss projection instead of the highly-distorting Web Mercator.

> See the previous paragraph? We specified `projection: 'EPSG:2056'` in the print spec, just for that. **inkmap** will do the rest and download the projection definition on its own.

Configuring the layers is done like this:

#### `print.js`

```js
// ...

// there are shared parameters for both layers
// including resolutions, tile grid origin and matrix set
const genericLayer = {
  type: 'WMTS',
  requestEncoding: 'REST',
  matrixSet: 'EPSG:2056',
  projection: 'EPSG:2056',
  tileGrid: {
    resolutions: [
      4000, 3750, 3500, 3250, 3000, 2750, 2500, 2250, 2000,
      1750, 1500, 1250, 1000, 750, 650, 500, 250, 100, 50, 20
    ],
    origin: [2420000, 1350000]
  },
  attribution: '© Swisstopo'
};

// use the parameters above and target a background layer
const bgLayer = {
  ...genericLayer,
  url: 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/2056/{TileMatrix}/{TileCol}/{TileRow}.jpeg',
  opacity: 0.4,
};

// this targets specifically the hiking trails layer
const trailsLayer = {
  ...genericLayer,
  url: 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swisstlm3d-wanderwege/default/current/2056/{TileMatrix}/{TileCol}/{TileRow}.png',
};

// ...
```

WMTS layers need a proper tile grid configuration to be displayed correctly, including: an array of resolutions for each zoom level, a tile grid origin, a matrix set id, and sometimes other parameters. Setting this up is non trivial and for the purpose of this article I have gotten inspiration from [existing examples](https://codesandbox.io/s/vanilla-openlayers-with-a-wms-layer-06h77?file=/src/config.js) (gotten from the [geo.admin.ch API doc](https://api3.geo.admin.ch/api/examples.html)).

## Binding it together

We're almost there! Let's use our shiny new `printAndDownload` function in the main module:

#### `app.js`
```js
// add this import at the top
import { printAndDownload } from './print';

// ...

// bind the print button click handler
document.getElementById('print-btn')
  .addEventListener('click', () => {
    printAndDownload(rectangle.getGeometry());
  });
```

And now, back to the application. Nothing has changed visually, but if you click the "print" button and wait a few seconds... Bam! You received the printed map, which should look like this:

<figure>
<p><img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i1z4riw2w9ken46lidrb.png" alt="A map of switzerland with colored hiking trails" loading="lazy"></p>
<p><figcaption>Spaghetti alla matriciana</figcaption></p>
</figure>

Not very readable as it covers a large part of the country, but you can definitely select a smaller region and print it again! And lo and behold, in the lower left corner: _a scale bar_!!

Having an image is great but... could we maybe have an actual PDF document to print instead? That would be nice!

## Finishing the job

To generate a PDF document we will bring in another runtime dependency (hopefully the last), [jsPDF](http://raw.githack.com/MrRio/jsPDF/master/docs/):

```bash
$ npm install --save jspdf
```

Let's use this new toy in the `print` module:

#### `print.js`

```js
// add this import at the top
import { jsPDF } from "jspdf";

// ...

export function printAndDownload(rectangleGeometry) {

  // ...

  // let's print!
  print({
    // ...
  }).then(imageBlob => {
    // initializes the PDF document
    const doc = new jsPDF({
      orientation: 'landscape',
      unit: 'mm',
      format: 'a4',
      putOnlyUsedFonts: true,
    });

    // create an Object URL from the map image blob
    // and add it to the PDF
    const imgUrl = URL.createObjectURL(imageBlob);
    doc.addImage(imgUrl, 'JPEG', 10, 30, size[0], size[1]);

    // add a title
    doc.setFont('times', 'bold');
    doc.setFontSize(20);
    doc.text('This is going to be great.', 148.5, 15, null, null, 'center');

    // download the result
    doc.save('hiking-trails.pdf');
  });
}
```

Now you can click "print" and you will receive an actual PDF document!

<figure>
<p><img src="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eqcv7fsiou94bszxo1fj.png" alt="A print preview of a PDF document" loading="lazy"></p>
<p><figcaption>Spoilt for choice.</figcaption></p>
</figure>

The only thing left for you is to print this on an A4, pack up your bags and go wandering towards your destiny. Or... the nearest bus stop.

# Conclusion

I hope this article made sense and that you had fun reading it and experimenting. Printing maps is _not_ straightforward, but it all makes sense when using the right tools for the right job.

Also, all the software used in the article are opensource, so please don't hesitate to reach the communities and contribute if you feel like it! Even a well-written bug report helps a lot.

Note that the project showcased in the article can be found [here](https://github.com/jahow/hiking-trails-print), and it even has a [live demo](https://jahow.github.io/hiking-trails-print) if you want to spoil yourself!

Thanks, happy coding!