inkmap
a library for generating high-quality, printable maps.

Give inkmap a JSON spec and it will use it to generate a PNG image in the background.

Use the print API to begin printing a spec. This function returns a Promise which resolves to a Blob containing the map image.

Generate!
import { downloadBlob, print } from '@camptocamp/inkmap';

const root = document.getElementById('example-01');
const btn = /** @type {CustomButton} */ root.querySelector('custom-button');
const spec = /** @type {PrintSpecEditor} */ root.querySelector('print-spec');

// make sure the spec is valid to allow printing
spec.onValidityCheck((valid) => (btn.enabled = valid));

btn.addEventListener('click', async () => {
  btn.showSpinner();

  // create a job, get a promise that resolves when the job is finished
  const blob = await print(spec.value);

  btn.hideSpinner();

  downloadBlob(blob, 'inkmap.png');
});

Showing progress

inkmap also enables finer monitoring by making the job status observable.

Use the enqueuePrint API to start a print job asynchronously. This function returns a Promise which resolves to a job id. You can then feed the job id to getJobStatus which will return an observable that you can subscribe to, emitting updates on the given print job status.

Generate!
import { downloadBlob, getJobStatus, queuePrint } from '@camptocamp/inkmap';

const root = document.getElementById('example-02');
const btn = /** @type {CustomButton} */ root.querySelector('custom-button');
const bar = /** @type {CustomProgress} */ root.querySelector('custom-progress');
const spec = /** @type {PrintSpecEditor} */ root.querySelector('print-spec');

// make sure the spec is valid to allow printing
spec.onValidityCheck((valid) => (btn.enabled = valid));

btn.addEventListener('click', async () => {
  btn.showSpinner();

  // display the job progress
  bar.progress = 0;
  bar.status = 'pending';

  // create a job, get a promise that resolves with the job id
  const jobId = await queuePrint(spec.value);

  getJobStatus(jobId).subscribe((printStatus) => {
    // update the job progress
    bar.progress = printStatus.progress;
    bar.status = printStatus.status;

    // job is finished
    if (printStatus.progress === 1) {
      btn.hideSpinner();
      downloadBlob(printStatus.imageBlob, 'inkmap.png');
    }
  });
});

Canceling a job

inkmap also enables job cancellation.

Use cancelJob after having started a job with enqueuePrint.

Generate!
import {
  cancelJob,
  downloadBlob,
  getJobStatus,
  queuePrint,
} from '@camptocamp/inkmap';

const root = document.getElementById('example-03');
const btn = /** @type {CustomButton} */ root.querySelector('custom-button');
const bar = /** @type {CustomProgress} */ root.querySelector('custom-progress');
const spec = /** @type {PrintSpecEditor} */ root.querySelector('print-spec');
const btnCancel = /** @type {Button} */ root.querySelector('.cancel-btn');

// make sure the spec is valid to allow printing
spec.onValidityCheck((valid) => (btn.enabled = valid));

let jobId;

btn.addEventListener('click', async () => {
  btn.showSpinner();

  // display the job progress
  bar.progress = 0;
  bar.status = 'pending';

  // create a job, get a promise that resolves with the job id
  jobId = await queuePrint(spec.value);

  getJobStatus(jobId).subscribe((printStatus) => {
    // update the job progress
    bar.progress = printStatus.progress;
    bar.status = printStatus.status;

    // job is finished or canceled
    if (printStatus.progress === 1 || printStatus.progress === -1) {
      btn.hideSpinner();
    }

    // job is finished
    if (printStatus.progress === 1) {
      downloadBlob(printStatus.imageBlob, 'inkmap.png');
    }
  });
});

btnCancel.addEventListener('click', async () => {
  // cancel job based on job id
  cancelJob(jobId);
});

Monitoring multiple jobs

Multiple jobs can be monitored at once through a long lived observable.

Use getJobsStatus to receive a long-lived observable that you can subscribe to and which will regularly give you status updates on all running jobs.

Add to the queue
import {
  getJobsStatus,
  queuePrint,
  downloadBlob,
  getJobStatus,
} from '@camptocamp/inkmap';

const root = document.getElementById('example-04');
const btn = /** @type {CustomButton} */ root.querySelector('custom-button');
const bars = /** @type {ProgressBars} */ root.querySelector('progress-bars');
const spec = /** @type {PrintSpecEditor} */ root.querySelector('print-spec');

// make sure the spec is valid to allow printing
spec.onValidityCheck((valid) => (btn.enabled = valid));

// subscribe to the jobs status updates
// ATTENTION! subscriptions to long-running observables might cause memory leaks!
getJobsStatus().subscribe((jobs) => {
  bars.jobsStatus = jobs;
});

btn.addEventListener('click', async () => {
  // create a job, get a promise that resolves with the job id
  const jobId = await queuePrint(spec.value);

  getJobStatus(jobId).subscribe((printStatus) => {
    // job is finished
    if (printStatus.progress === 1) {
      downloadBlob(printStatus.imageBlob, `inkmap-${jobId}.png`);
    }
  });
});

Printing to PDF

inkmap jobs give back a Blob which can be inserted into a PDF. This is for example supported by jsPDF, as showcased here.

Generate!
import { print, getAttributionsText, getNorthArrow } from '@camptocamp/inkmap';
import { jsPDF } from 'jspdf';
import { getScaleBar } from '../../src/main';

const root = document.getElementById('example-05');
const btn = /** @type {CustomButton} */ root.querySelector('custom-button');
const spec = /** @type {PrintSpecEditor} */ root.querySelector('print-spec');

// make sure the spec is valid to allow printing
spec.onValidityCheck((valid) => (btn.enabled = valid));

btn.addEventListener('click', async () => {
  btn.showSpinner();

  const mapWidth = 277; // mm
  const mapHeight = 150; // mm

  // Force map size to fit the PDF document
  const specValue = {
    ...spec.value,
    size: [mapWidth, mapHeight, 'mm'],
    attributions: null, // do not print widgets on the map
    northArrow: false,
    scaleBar: false,
  };

  // create a job, get a promise that resolves when the job is finished
  const blob = await print(specValue);

  btn.hideSpinner();

  // initializes the PDF document
  const doc = new jsPDF({
    orientation: 'landscape',
    unit: 'mm',
    format: 'a4', // 210 by 297mm
    putOnlyUsedFonts: true,
  });

  // create an Object URL from the map image blob and add it to the PDF
  const imgUrl = URL.createObjectURL(blob);
  doc.addImage(imgUrl, 'JPEG', 10, 40, mapWidth, mapHeight);

  // add a title
  doc.setFont('times', 'bold');
  doc.setFontSize(20);
  doc.text('A fantastic map.', 148.5, 13, null, null, 'center');

  // add north arrow
  const arrow = getNorthArrow(specValue, [16, 'mm']);
  const arrowSizeMm = arrow.getRealWorldDimensions('mm');
  doc.addImage(
    arrow.getImage(),
    'PNG',
    140,
    21,
    arrowSizeMm[0],
    arrowSizeMm[1]
  );

  // add scalebar next to the north arrow
  const scalebar = getScaleBar(specValue, [30, 'mm']);
  const scalebarSizeMm = scalebar.getRealWorldDimensions('mm');
  doc.addImage(
    scalebar.getImage(),
    'PNG',
    287 - scalebarSizeMm[0],
    37 - scalebarSizeMm[1],
    scalebarSizeMm[0],
    scalebarSizeMm[1]
  );

  // add a creation date
  doc.setFont('courier', 'normal');
  doc.setFontSize(12);
  doc.text(
    `Print date : ${new Date().toLocaleString()}`,
    10,
    200,
    null,
    null,
    'left'
  );

  // add attribution
  doc.setFont('courier', 'normal');
  doc.setFontSize(12);
  doc.text(getAttributionsText(spec.value), 287, 200, null, null, 'right');

  // download the result
  doc.save('inkmap.pdf');
});

Using projections

inkmap supports a large amount of projections for rendering the map. Projections can be defined and registered using registerProjection. If the map projection is not registered, inkmap will look it up on epsg.io.

Under the hood, inkmap uses proj4js.

Generate!
import { downloadBlob, print, registerProjection } from '@camptocamp/inkmap';

const root = document.getElementById('example-06');
const btn = /** @type {CustomButton} */ root.querySelector('custom-button');
const spec = /** @type {PrintSpecEditor} */ root.querySelector('print-spec');

// make sure the spec is valid to allow printing
spec.onValidityCheck((valid) => (btn.enabled = valid));

btn.addEventListener('click', async () => {
  btn.showSpinner();

  // registers the projection EPSG:2154
  registerProjection({
    name: 'EPSG:2154',
    bbox: [51.56, -9.86, 41.15, 10.38],
    proj4:
      '+proj=lcc +lat_1=49 +lat_2=44 +lat_0=46.5 +lon_0=3 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs',
  });

  // create a job, get a promise that resolves when the job is finished
  const blob = await print(spec.value);

  btn.hideSpinner();

  downloadBlob(blob, 'inkmap.png');
});

Error handling

When errors are encountered while loading data for one or several layers, inkmap includes a list of error objects with urls in the status updates.

Generate!
import { downloadBlob, getJobStatus, queuePrint } from '@camptocamp/inkmap';

const root = document.getElementById('example-07');
const btn = /** @type {CustomButton} */ root.querySelector('custom-button');
const spec = /** @type {PrintSpecEditor} */ root.querySelector('print-spec');
const errors = root.querySelector('#errors');

// make sure the spec is valid to allow printing
spec.onValidityCheck((valid) => (btn.enabled = valid));

btn.addEventListener('click', async () => {
  btn.showSpinner();

  // create a job, get a promise that resolves with the job id
  const jobId = await queuePrint(spec.value);

  getJobStatus(jobId).subscribe((status) => {
    // display the job progress
    btn.progress = status.progress;

    // job is finished
    if (status.progress === 1) {
      btn.hideSpinner();

      // display urls with errors
      if (status.sourceLoadErrors.length > 0) {
        let errorMessage = 'The following layers encountered errors:<br>';
        status.sourceLoadErrors.forEach((element) => {
          errorMessage = `${errorMessage} - ${element.url}<br>`;
        });
        errors.innerHTML = errorMessage;
      } else {
        errors.innerHTML = '';
      }

      downloadBlob(status.imageBlob, 'inkmap.png');
    }
  });
});

Printing legends

This examples shows how to print legends for vector and rasterlayers by providing the configuration option legend on a layer with a value of true. Currently this supports WMS, WMTS, GeoJSON and WFS layers.

The print will generate a separate image containing all the legends at once.

Generate mapGenerate legend
import { downloadBlob, getJobStatus, queuePrint } from '@camptocamp/inkmap';
import { createLegends } from '../../src/main';

const root = document.getElementById('example-08');
const mapBtn = /** @type {CustomButton} */ root.querySelector(
  'custom-button.map-btn'
);
const legendBtn = /** @type {CustomButton} */ root.querySelector(
  'custom-button.legend-btn'
);
root.querySelector('custom-button.legend-btn');
const bar = /** @type {CustomProgress} */ root.querySelector('custom-progress');
const spec = /** @type {PrintSpecEditor} */ root.querySelector('print-spec');

// make sure the spec is valid to allow printing
spec.onValidityCheck((valid) => {
  mapBtn.enabled = valid;
  legendBtn.enabled = valid;
});

mapBtn.addEventListener('click', async () => {
  mapBtn.showSpinner();

  // display the job progress
  bar.progress = 0;
  bar.status = 'pending';

  // create a job, get a promise that resolves with the job id
  const jobId = await queuePrint(spec.value);

  getJobStatus(jobId).subscribe((printStatus) => {
    // update the job progress
    bar.progress = printStatus.progress;
    bar.status = printStatus.status;

    // job is finished
    if (printStatus.progress === 1) {
      mapBtn.hideSpinner();
      downloadBlob(printStatus.imageBlob, 'inkmap.png');
    }
  });
});

legendBtn.addEventListener('click', async () => {
  const blob = await createLegends(spec.value);
  downloadBlob(blob, 'legend.svg');
});