Printing maps in the browser, a story

Olivia Guyot - Mar 30 '21 - - Dev Community

Photo by Stephen Monroe on Unsplash

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

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 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, 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

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 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:

$ npm init
$ npm install --save-dev webpack webpack-cli webpack-dev-server babel-loader @babel/core @babel/preset-env style-loader css-loader
Enter fullscreen mode Exit fullscreen mode

The webpack configuration that I used is here.

Note that I would have preferred to use Parcel 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 as a runtime dependency:

$ npm install --save ol
Enter fullscreen mode Exit fullscreen mode

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

index.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>
Enter fullscreen mode Exit fullscreen mode

app.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]
});
Enter fullscreen mode Exit fullscreen mode

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

A map of switzerland

It all happened so fast!

Behold! Interactivity.

Using the OpenLayers API 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).

To make it easily modifiable we are going to use a wonderful extension library called ol-ext, and more specifically its Transform interaction. To install it:

$ npm install --save ol-ext
Enter fullscreen mode Exit fullscreen mode

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

app.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);
  });
Enter fullscreen mode Exit fullscreen mode

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.

A map with a rectangle over it

Did I just play with this for 10 minutes?

Note that these coordinates are expressed in Web Mercator 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:

$ npm install --save @camptocamp/inkmap
Enter fullscreen mode Exit fullscreen mode

inkmap offers a simple API in the form of the print function, which needs a JSON spec to do its job. The JSON spec will look like this:

{
  "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
}
Enter fullscreen mode Exit fullscreen mode

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

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')
  );
}
Enter fullscreen mode Exit fullscreen mode

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). 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

// ...

// 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',
};

// ...
Enter fullscreen mode Exit fullscreen mode

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 (gotten from the geo.admin.ch API doc).

Binding it together

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

app.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());
  });
Enter fullscreen mode Exit fullscreen mode

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:

A map of switzerland with colored hiking trails

Spaghetti alla matriciana

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:

$ npm install --save jspdf
Enter fullscreen mode Exit fullscreen mode

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

print.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');
  });
}
Enter fullscreen mode Exit fullscreen mode

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

A print preview of a PDF document

Spoilt for choice.

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, and it even has a live demo if you want to spoil yourself!

Thanks, happy coding!

. . . . . . . . . . . . . . . . . . . . . . . .