Edit

Vector tiles rendered in an offscreen canvas

worker offscreencanvas vector-tiles

Example of a map that delegates rendering to a worker.

The map in this example is rendered in a web worker, using OffscreenCanvas. Note: This is currently only supported in Chrome and Edge.

index.html<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Vector tiles rendered in an offscreen canvas</title>
    <!-- The line below is only needed for old environments like Internet Explorer and Android 4.x -->
    <script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=fetch,requestAnimationFrame,Element.prototype.classList,URL"></script>
    <style>
      .map {
        width: 100%;
        height:400px;
      }
      .map {
        background: rgba(232, 230, 223, 1);
      }
      .ol-rotate {
        left: .5em;
        bottom: .5em;
        top: unset;
        right: unset;
      }    </style>
  </head>
  <body>
    <div id="map" class="map"></div>
    <script src="index.js"></script>
  </body>
</html>
index.jsimport 'ol/ol.css';
import Map from 'ol/Map';
import View from 'ol/View';
import Layer from 'ol/layer/Layer';
import {compose, create} from 'ol/transform';
import {createTransformString} from 'ol/render/canvas';
import {createXYZ} from 'ol/tilegrid';
import {FullScreen} from 'ol/control';
import stringify from 'json-stringify-safe';
import Source from 'ol/source/Source';

var worker = new Worker('./worker.js');

var container, transformContainer, canvas, rendering, workerFrameState, mainThreadFrameState;

// Transform the container to account for the differnece between the (newer)
// main thread frameState and the (older) worker frameState
function updateContainerTransform() {
  if (workerFrameState) {
    var viewState = mainThreadFrameState.viewState;
    var renderedViewState = workerFrameState.viewState;
    var center = viewState.center;
    var resolution = viewState.resolution;
    var rotation = viewState.rotation;
    var renderedCenter = renderedViewState.center;
    var renderedResolution = renderedViewState.resolution;
    var renderedRotation = renderedViewState.rotation;
    var transform = create();
    // Skip the extra transform for rotated views, because it will not work
    // correctly in that case
    if (!rotation) {
      compose(transform,
        (renderedCenter[0] - center[0]) / resolution,
        (center[1] - renderedCenter[1]) / resolution,
        renderedResolution / resolution, renderedResolution / resolution,
        rotation - renderedRotation,
        0, 0);
    }
    transformContainer.style.transform = createTransformString(transform);
  }
}

var map = new Map({
  layers: [
    new Layer({
      render: function(frameState) {
        if (!container) {
          container = document.createElement('div');
          container.style.position = 'absolute';
          container.style.width = '100%';
          container.style.height = '100%';
          transformContainer = document.createElement('div');
          transformContainer.style.position = 'absolute';
          transformContainer.style.width = '100%';
          transformContainer.style.height = '100%';
          container.appendChild(transformContainer);
          canvas = document.createElement('canvas');
          canvas.style.position = 'absolute';
          canvas.style.left = '0';
          canvas.style.transformOrigin = 'top left';
          transformContainer.appendChild(canvas);
        }
        mainThreadFrameState = frameState;
        updateContainerTransform();
        if (!rendering) {
          rendering = true;
          worker.postMessage({
            action: 'render',
            frameState: JSON.parse(stringify(frameState))
          });
        } else {
          frameState.animate = true;
        }
        return container;
      },
      source: new Source({
        attributions: [
          '<a href="https://www.maptiler.com/copyright/" target="_blank">© MapTiler</a>',
          '<a href="https://www.openstreetmap.org/copyright" target="_blank">© OpenStreetMap contributors</a>'
        ]
      })
    })
  ],
  target: 'map',
  view: new View({
    resolutions: createXYZ({tileSize: 512}).getResolutions89,
    center: [0, 0],
    zoom: 2
  })
});
map.addControl(new FullScreen());

// Worker messaging and actions
worker.addEventListener('message', function (message) {
  if (message.data.action === 'loadImage') {
    // Image loader for ol-mapbox-style
    var image = new Image();
    image.crossOrigin = 'anonymous';
    image.addEventListener('load', function() {
      createImageBitmap(image, 0, 0, image.width, image.height).then(function (imageBitmap) {
        worker.postMessage({
          action: 'imageLoaded',
          image: imageBitmap,
          src: message.data.src
        }, [imageBitmap]);
      });
    });
    image.src = event.data.src;
  } else if (message.data.action === 'requestRender') {
    // Worker requested a new render frame
    map.render();
  } else if (canvas && message.data.action === 'rendered') {
    // Worker provies a new render frame
    requestAnimationFrame(function() {
      var imageData = message.data.imageData;
      canvas.width = imageData.width;
      canvas.height = imageData.height;
      canvas.getContext('2d').drawImage(imageData, 0, 0);
      canvas.style.transform = message.data.transform;
      workerFrameState = message.data.frameState;
      updateContainerTransform();
    });
    rendering = false;
  }
});
worker.jsimport VectorTileLayer from 'ol/layer/VectorTile';
import VectorTileSource from 'ol/source/VectorTile';
import MVT from 'ol/format/MVT';
import {Projection} from 'ol/proj';
import TileQueue from 'ol/TileQueue';
import {getTilePriority as tilePriorityFunction} from 'ol/TileQueue';
import {renderDeclutterItems} from 'ol/render';
import styleFunction from 'ol-mapbox-style/dist/stylefunction';
import {inView} from 'ol/layer/Layer';
import stringify from 'json-stringify-safe';

/** @type {any} */
const worker = self;

let frameState, pixelRatio, rendererTransform;
const canvas = new OffscreenCanvas(1, 1);
// OffscreenCanvas does not have a style, so we mock it
canvas.style = {};
const context = canvas.getContext('2d');

const sources = {
  landcover: new VectorTileSource({
    maxZoom: 9,
    format: new MVT(),
    url: 'https://api.maptiler.com/tiles/landcover/{z}/{x}/{y}.pbf?key=get_your_own_D6rA4zTHduk6KOKTXzGB'
  }),
  contours: new VectorTileSource({
    minZoom: 9,
    maxZoom: 14,
    format: new MVT(),
    url: 'https://api.maptiler.com/tiles/contours/{z}/{x}/{y}.pbf?key=get_your_own_D6rA4zTHduk6KOKTXzGB'
  }),
  openmaptiles: new VectorTileSource({
    format: new MVT(),
    maxZoom: 14,
    url: 'https://api.maptiler.com/tiles/v3/{z}/{x}/{y}.pbf?key=get_your_own_D6rA4zTHduk6KOKTXzGB'
  })
};
const layers = [];

// Font replacement so we do not need to load web fonts in the worker
function getFont(font) {
  return font[0]
    .replace('Noto Sans', 'serif')
    .replace('Roboto', 'sans-serif');
}

function loadStyles() {
  const styleUrl = 'https://api.maptiler.com/maps/topo/style.json?key=get_your_own_D6rA4zTHduk6KOKTXzGB';

  fetch(styleUrl).then(data => data.json()).then(styleJson => {
    const buckets = [];
    let currentSource;
    styleJson.layers.forEach(layer => {
      if (!layer.source) {
        return;
      }
      if (currentSource !== layer.source) {
        currentSource = layer.source;
        buckets.push({
          source: layer.source,
          layers: []
        });
      }
      buckets[buckets.length - 1].layers.push(layer.id);
    });

    const spriteUrl = styleJson.sprite + (pixelRatio > 1 ? '@2x' : '') + '.json';
    const spriteImageUrl = styleJson.sprite + (pixelRatio > 1 ? '@2x' : '') + '.png';
    fetch(spriteUrl).then(data => data.json()).then(spriteJson => {
      buckets.forEach(bucket => {
        const source = sources[bucket.source];
        if (!source) {
          return;
        }
        const layer = new VectorTileLayer({
          declutter: true,
          source,
          minZoom: source.getTileGrid().getMinZoom()
        });
        layer.getRenderer().useContainer = function(target, transform) {
          this.containerReused = this.getLayer() !== layers[0];
          this.canvas = canvas;
          this.context = context;
          this.container = {
            firstElementChild: canvas
          };
          rendererTransform = transform;
        };
        styleFunction(layer, styleJson, bucket.layers, undefined, spriteJson, spriteImageUrl, getFont);
        layers.push(layer);
      });
      worker.postMessage({action: 'requestRender'});
    });
  });
}

// Minimal map-like functionality for rendering

const tileQueue = new TileQueue(
  (tile, tileSourceKey, tileCenter, tileResolution) => tilePriorityFunction(frameState, tile, tileSourceKey, tileCenter, tileResolution),
  () => worker.postMessage({action: 'requestRender'}));

const maxTotalLoading = 8;
const maxNewLoads = 2;

worker.addEventListener('message', event => {
  if (event.data.action !== 'render') {
    return;
  }
  frameState = event.data.frameState;
  if (!pixelRatio) {
    pixelRatio = frameState.pixelRatio;
    loadStyles();
  }
  frameState.tileQueue = tileQueue;
  frameState.viewState.projection.__proto__ = Projection.prototype;
  layers.forEach(layer => {
    if (inView(layer.getLayerState(), frameState.viewState)) {
      const renderer = layer.getRenderer();
      renderer.renderFrame(frameState, canvas);
    }
  });
  renderDeclutterItems(frameState, null);
  if (tileQueue.getTilesLoading() < maxTotalLoading) {
    tileQueue.reprioritize();
    tileQueue.loadMoreTiles(maxTotalLoading, maxNewLoads);
  }
  const imageData = canvas.transferToImageBitmap();
  worker.postMessage({
    action: 'rendered',
    imageData: imageData,
    transform: rendererTransform,
    frameState: JSON.parse(stringify(frameState))
  }, [imageData]);
});
package.json{
  "name": "offscreen-canvas",
  "dependencies": {
    "ol": "6.3.1",
    "ol-mapbox-style": "^6.1.1",
    "json-stringify-safe": "^5.0.1"
  },
  "devDependencies": {
    "parcel": "1.11.0"
  },
  "scripts": {
    "start": "parcel index.html",
    "build": "parcel build --experimental-scope-hoisting --public-url . index.html"
  }
}