diff --git a/examples/mapbox-style.css b/examples/mapbox-style.css new file mode 100644 index 0000000000..c79f84a2c1 --- /dev/null +++ b/examples/mapbox-style.css @@ -0,0 +1,6 @@ +.ol-rotate { + left: .5em; + bottom: .5em; + top: unset; + right: unset; +} \ No newline at end of file diff --git a/examples/mapbox-style.html b/examples/mapbox-style.html index 8285734955..1c88215464 100644 --- a/examples/mapbox-style.html +++ b/examples/mapbox-style.html @@ -1,5 +1,5 @@ --- -layout: example-verbatim.html +layout: example.html title: Vector tiles created from a Mapbox Style object shortdesc: Example of using ol-mapbox-style with tiles from maptiler.com. docs: > @@ -10,27 +10,4 @@ cloak: - key: get_your_own_D6rA4zTHduk6KOKTXzGB value: Get your own API key at https://www.maptiler.com/cloud/ --- - - - - - - - Mapbox Style objects with ol-mapbox-style - - - - - -
- - - - +
diff --git a/examples/mapbox-style.js b/examples/mapbox-style.js index 819177a69d..a774155526 100644 --- a/examples/mapbox-style.js +++ b/examples/mapbox-style.js @@ -1,3 +1,6 @@ import apply from 'ol-mapbox-style'; +import FullScreen from '../src/ol/control/FullScreen.js'; -apply('map', 'https://api.maptiler.com/maps/topo/style.json?key=get_your_own_D6rA4zTHduk6KOKTXzGB'); +apply('map', 'https://api.maptiler.com/maps/topo/style.json?key=get_your_own_D6rA4zTHduk6KOKTXzGB').then(function(map) { + map.addControl(new FullScreen()); +}); diff --git a/examples/offscreen-canvas.css b/examples/offscreen-canvas.css new file mode 100644 index 0000000000..10bacb9a63 --- /dev/null +++ b/examples/offscreen-canvas.css @@ -0,0 +1,9 @@ +.map { + background: rgba(232, 230, 223, 1); +} +.ol-rotate { + left: .5em; + bottom: .5em; + top: unset; + right: unset; +} \ No newline at end of file diff --git a/examples/offscreen-canvas.html b/examples/offscreen-canvas.html new file mode 100644 index 0000000000..741d469e23 --- /dev/null +++ b/examples/offscreen-canvas.html @@ -0,0 +1,10 @@ +--- +layout: example.html +title: Vector tiles rendered in an offscreen canvas +shortdesc: Example of a map that delegates rendering to a worker. +docs: > + The map in this example is rendered in a web worker, using `OffscreenCanvas`. **Note:** This is currently only supported in Chrome and Edge. +tags: "worker, offscreencanvas, vector-tiles" +experimental: true +--- +
diff --git a/examples/offscreen-canvas.js b/examples/offscreen-canvas.js new file mode 100644 index 0000000000..180e5c990e --- /dev/null +++ b/examples/offscreen-canvas.js @@ -0,0 +1,125 @@ +import Map from '../src/ol/Map.js'; +import View from '../src/ol/View.js'; +import Layer from '../src/ol/layer/Layer.js'; +import Worker from 'worker-loader!./offscreen-canvas.worker.js'; //eslint-disable-line +import {compose, create} from '../src/ol/transform.js'; +import {createTransformString} from '../src/ol/render/canvas.js'; +import {createXYZ} from '../src/ol/tilegrid.js'; +import {FullScreen} from '../src/ol/control.js'; +import stringify from 'json-stringify-safe'; +import Source from '../src/ol/source/Source.js'; + +const worker = new Worker(); + +let 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) { + const viewState = mainThreadFrameState.viewState; + const renderedViewState = workerFrameState.viewState; + const center = viewState.center; + const resolution = viewState.resolution; + const rotation = viewState.rotation; + const renderedCenter = renderedViewState.center; + const renderedResolution = renderedViewState.resolution; + const renderedRotation = renderedViewState.rotation; + const 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); + } +} + +const 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: [ + '© MapTiler', + '© OpenStreetMap contributors' + ] + }) + }) + ], + 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', message => { + if (message.data.action === 'loadImage') { + // Image loader for ol-mapbox-style + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.addEventListener('load', function() { + createImageBitmap(image, 0, 0, image.width, image.height).then(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() { + const 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; + } +}); diff --git a/examples/offscreen-canvas.worker.js b/examples/offscreen-canvas.worker.js new file mode 100644 index 0000000000..b03f0be61d --- /dev/null +++ b/examples/offscreen-canvas.worker.js @@ -0,0 +1,136 @@ +import VectorTileLayer from '../src/ol/layer/VectorTile.js'; +import VectorTileSource from '../src/ol/source/VectorTile.js'; +import MVT from '../src/ol/format/MVT.js'; +import {Projection} from '../src/ol/proj.js'; +import TileQueue from '../src/ol/TileQueue.js'; +import {getTilePriority as tilePriorityFunction} from '../src/ol/TileQueue.js'; +import {renderDeclutterItems} from '../src/ol/render.js'; +import styleFunction from 'ol-mapbox-style/dist/stylefunction.js'; +import {inView} from '../src/ol/layer/Layer.js'; +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]); +}); diff --git a/examples/resources/common.js b/examples/resources/common.js index 893fe51c1a..a59685d3d5 100644 --- a/examples/resources/common.js +++ b/examples/resources/common.js @@ -57,6 +57,8 @@ event.preventDefault(); const html = document.getElementById('example-html-source').innerText; const js = document.getElementById('example-js-source').innerText; + const workerContainer = document.getElementById('example-worker-source'); + const worker = workerContainer ? workerContainer.innerText : undefined; const pkgJson = document.getElementById('example-pkg-source').innerText; const form = document.getElementById('codepen-form'); @@ -68,22 +70,28 @@ Promise.all(promises) .then(results => { - const data = { - files: { - 'index.html': { - content: html - }, - 'index.js': { - content: js - }, - "package.json": { - content: pkgJson - }, - 'sandbox.config.json': { - content: '{"template": "parcel"}' - } + const files = { + 'index.html': { + content: html + }, + 'index.js': { + content: js + }, + "package.json": { + content: pkgJson + }, + 'sandbox.config.json': { + content: '{"template": "parcel"}' } }; + if (worker) { + files['worker.js'] = { + content: worker + } + } + const data = { + files: files + }; for (let i = 0; i < localResources.length; i++) { data.files[localResources[i]] = results[i]; diff --git a/examples/resources/mapbox-streets-v6-style.js b/examples/resources/mapbox-streets-v6-style.js index abd5babca3..afc1d8605d 100644 --- a/examples/resources/mapbox-streets-v6-style.js +++ b/examples/resources/mapbox-streets-v6-style.js @@ -309,4 +309,4 @@ function createMapboxStreetsV6Style(Style, Fill, Stroke, Icon, Text) { styles.length = length; return styles; }; -} +} \ No newline at end of file diff --git a/examples/templates/example.html b/examples/templates/example.html index a45f88e7be..b0c5cee4b7 100644 --- a/examples/templates/example.html +++ b/examples/templates/example.html @@ -160,6 +160,14 @@
index.jsimport 'ol/ol.css';
 {{ js.source }}
+{{#if worker.source}} +
+
+ Copy +
+
worker.js{{ worker.source }}
+
+{{/if}}
Copy @@ -167,7 +175,6 @@
package.json{{ pkgJson }}
- {{{ js.tag }}} diff --git a/examples/webpack/example-builder.js b/examples/webpack/example-builder.js index 22a7867500..d25306b897 100644 --- a/examples/webpack/example-builder.js +++ b/examples/webpack/example-builder.js @@ -208,6 +208,10 @@ ExampleBuilder.prototype.render = async function(dir, chunk) { jsSource = jsSource.replace(new RegExp(entry.key, 'g'), entry.value); } } + // Remove worker loader import and modify `new Worker()` to add source + jsSource = jsSource.replace(/import Worker from 'worker-loader![^\n]*\n/g, ''); + jsSource = jsSource.replace('new Worker()', 'new Worker(\'./worker.js\')'); + data.js = { tag: ``, source: jsSource @@ -218,9 +222,33 @@ ExampleBuilder.prototype.render = async function(dir, chunk) { data.js.tag = prelude + data.js.tag; } + // check for worker js + const workerName = `${name}.worker.js`; + const workerPath = path.join(dir, workerName); + let workerSource; + try { + workerSource = await readFile(workerPath, readOptions); + } catch (err) { + // pass + } + if (workerSource) { + // remove "../src/" prefix and ".js" to have the same import syntax as the documentation + workerSource = workerSource.replace(/'\.\.\/src\//g, '\''); + workerSource = workerSource.replace(/\.js';/g, '\';'); + if (data.cloak) { + for (const entry of data.cloak) { + workerSource = workerSource.replace(new RegExp(entry.key, 'g'), entry.value); + } + } + data.worker = { + source: workerSource + }; + assets[workerName] = workerSource; + } + data.pkgJson = JSON.stringify({ name: name, - dependencies: getDependencies(jsSource), + dependencies: getDependencies(jsSource + workerSource ? `\n${workerSource}` : ''), devDependencies: { parcel: '1.11.0' }, diff --git a/package-lock.json b/package-lock.json index 9bced339b2..450a9667f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12693,6 +12693,28 @@ "errno": "~0.1.7" } }, + "worker-loader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz", + "integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==", + "dev": true, + "requires": { + "loader-utils": "^1.0.0", + "schema-utils": "^0.4.0" + }, + "dependencies": { + "schema-utils": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz", + "integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-keywords": "^3.1.0" + } + } + } + }, "wrap-ansi": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", diff --git a/package.json b/package.json index 2fa01fd358..2eca898191 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "jquery": "3.4.1", "jsdoc": "3.6.3", "jsdoc-plugin-typescript": "^2.0.5", + "json-stringify-safe": "^5.0.1", "karma": "^4.4.1", "karma-chrome-launcher": "3.1.0", "karma-coverage-istanbul-reporter": "^2.1.1", @@ -83,7 +84,7 @@ "loglevelnext": "^3.0.1", "marked": "0.8.2", "mocha": "7.1.1", - "ol-mapbox-style": "^6.0.0", + "ol-mapbox-style": "^6.1.0", "pixelmatch": "^5.1.0", "pngjs": "^3.4.0", "proj4": "2.6.0", @@ -104,6 +105,7 @@ "webpack-cli": "^3.3.2", "webpack-dev-middleware": "^3.6.2", "webpack-dev-server": "^3.3.1", + "worker-loader": "^2.0.0", "yargs": "^15.0.2" }, "eslintConfig": { diff --git a/src/ol/PluggableMap.js b/src/ol/PluggableMap.js index eb6c7646e4..792215e1cc 100644 --- a/src/ol/PluggableMap.js +++ b/src/ol/PluggableMap.js @@ -12,7 +12,7 @@ import MapProperty from './MapProperty.js'; import RenderEventType from './render/EventType.js'; import BaseObject, {getChangeEventType} from './Object.js'; import ObjectEventType from './ObjectEventType.js'; -import TileQueue from './TileQueue.js'; +import TileQueue, {getTilePriority} from './TileQueue.js'; import View from './View.js'; import ViewHint from './ViewHint.js'; import {assert} from './asserts.js'; @@ -24,7 +24,6 @@ import {TRUE} from './functions.js'; import {DEVICE_PIXEL_RATIO, IMAGE_DECODE, PASSIVE_EVENT_LISTENERS} from './has.js'; import LayerGroup from './layer/Group.js'; import {hasArea} from './size.js'; -import {DROP} from './structs/PriorityQueue.js'; import {create as createTransform, apply as applyTransform} from './transform.js'; import {toUserCoordinate, fromUserCoordinate} from './proj.js'; @@ -891,26 +890,7 @@ class PluggableMap extends BaseObject { * @return {number} Tile priority. */ getTilePriority(tile, tileSourceKey, tileCenter, tileResolution) { - // Filter out tiles at higher zoom levels than the current zoom level, or that - // are outside the visible extent. - const frameState = this.frameState_; - if (!frameState || !(tileSourceKey in frameState.wantedTiles)) { - return DROP; - } - if (!frameState.wantedTiles[tileSourceKey][tile.getKey()]) { - return DROP; - } - // Prioritize the highest zoom level tiles closest to the focus. - // Tiles at higher zoom levels are prioritized using Math.log(tileResolution). - // Within a zoom level, tiles are prioritized by the distance in pixels between - // the center of the tile and the center of the viewport. The factor of 65536 - // means that the prioritization should behave as desired for tiles up to - // 65536 * Math.log(2) = 45426 pixels from the focus. - const center = frameState.viewState.center; - const deltaX = tileCenter[0] - center[0]; - const deltaY = tileCenter[1] - center[1]; - return 65536 * Math.log(tileResolution) + - Math.sqrt(deltaX * deltaX + deltaY * deltaY) / tileResolution; + return getTilePriority(this.frameState_, tile, tileSourceKey, tileCenter, tileResolution); } /** diff --git a/src/ol/TileQueue.js b/src/ol/TileQueue.js index 994a8eeb1d..eb39b119d8 100644 --- a/src/ol/TileQueue.js +++ b/src/ol/TileQueue.js @@ -3,7 +3,7 @@ */ import TileState from './TileState.js'; import EventType from './events/EventType.js'; -import PriorityQueue from './structs/PriorityQueue.js'; +import PriorityQueue, {DROP} from './structs/PriorityQueue.js'; /** @@ -119,3 +119,34 @@ class TileQueue extends PriorityQueue { export default TileQueue; + + +/** + * @param {import('./PluggableMap.js').FrameState} frameState Frame state. + * @param {import("./Tile.js").default} tile Tile. + * @param {string} tileSourceKey Tile source key. + * @param {import("./coordinate.js").Coordinate} tileCenter Tile center. + * @param {number} tileResolution Tile resolution. + * @return {number} Tile priority. + */ +export function getTilePriority(frameState, tile, tileSourceKey, tileCenter, tileResolution) { + // Filter out tiles at higher zoom levels than the current zoom level, or that + // are outside the visible extent. + if (!frameState || !(tileSourceKey in frameState.wantedTiles)) { + return DROP; + } + if (!frameState.wantedTiles[tileSourceKey][tile.getKey()]) { + return DROP; + } + // Prioritize the highest zoom level tiles closest to the focus. + // Tiles at higher zoom levels are prioritized using Math.log(tileResolution). + // Within a zoom level, tiles are prioritized by the distance in pixels between + // the center of the tile and the center of the viewport. The factor of 65536 + // means that the prioritization should behave as desired for tiles up to + // 65536 * Math.log(2) = 45426 pixels from the focus. + const center = frameState.viewState.center; + const deltaX = tileCenter[0] - center[0]; + const deltaY = tileCenter[1] - center[1]; + return 65536 * Math.log(tileResolution) + + Math.sqrt(deltaX * deltaX + deltaY * deltaY) / tileResolution; +} diff --git a/src/ol/css.js b/src/ol/css.js index c855fae7ea..88a6d5c9dc 100644 --- a/src/ol/css.js +++ b/src/ol/css.js @@ -4,9 +4,13 @@ /** * @typedef {Object} FontParameters - * @property {Array} families * @property {string} style + * @property {string} variant * @property {string} weight + * @property {string} size + * @property {string} lineHeight + * @property {string} family + * @property {Array} families */ @@ -64,42 +68,52 @@ export const CLASS_CONTROL = 'ol-control'; */ export const CLASS_COLLAPSED = 'ol-collapsed'; +/** + * From http://stackoverflow.com/questions/10135697/regex-to-parse-any-css-font + * @type {RegExp} + */ +const fontRegEx = new RegExp([ + '^\\s*(?=(?:(?:[-a-z]+\\s*){0,2}(italic|oblique))?)', + '(?=(?:(?:[-a-z]+\\s*){0,2}(small-caps))?)', + '(?=(?:(?:[-a-z]+\\s*){0,2}(bold(?:er)?|lighter|[1-9]00 ))?)', + '(?:(?:normal|\\1|\\2|\\3)\\s*){0,3}((?:xx?-)?', + '(?:small|large)|medium|smaller|larger|[\\.\\d]+(?:\\%|in|[cem]m|ex|p[ctx]))', + '(?:\\s*\\/\\s*(normal|[\\.\\d]+(?:\\%|in|[cem]m|ex|p[ctx])?))', + '?\\s*([-,\\"\\\'\\sa-z]+?)\\s*$' +].join(''), 'i'); +const fontRegExMatchIndex = [ + 'style', + 'variant', + 'weight', + 'size', + 'lineHeight', + 'family' +]; /** * Get the list of font families from a font spec. Note that this doesn't work * for font families that have commas in them. - * @param {string} The CSS font property. - * @return {FontParameters} The font families (or null if the input spec is invalid). + * @param {string} fontSpec The CSS font property. + * @return {FontParameters} The font parameters (or null if the input spec is invalid). */ -export const getFontParameters = (function() { - /** - * @type {CSSStyleDeclaration} - */ - let style; - /** - * @type {Object} - */ - const cache = {}; - return function(font) { - if (!style) { - style = document.createElement('div').style; +export const getFontParameters = function(fontSpec) { + const match = fontSpec.match(fontRegEx); + if (!match) { + return null; + } + const style = /** @type {FontParameters} */ ({ + lineHeight: 'normal', + size: '1.2em', + style: 'normal', + weight: 'normal', + variant: 'normal' + }); + for (let i = 0, ii = fontRegExMatchIndex.length; i < ii; ++i) { + const value = match[i + 1]; + if (value !== undefined) { + style[fontRegExMatchIndex[i]] = value; } - if (!(font in cache)) { - style.font = font; - const family = style.fontFamily; - const fontWeight = style.fontWeight; - const fontStyle = style.fontStyle; - style.font = ''; - if (!family) { - return null; - } - const families = family.split(/,\s?/); - cache[font] = { - families: families, - weight: fontWeight, - style: fontStyle - }; - } - return cache[font]; - }; -})(); + } + style.families = style.family.split(/,\s?/); + return style; +}; diff --git a/src/ol/dom.js b/src/ol/dom.js index ad10c14671..e3c403cc99 100644 --- a/src/ol/dom.js +++ b/src/ol/dom.js @@ -1,8 +1,11 @@ +import {WORKER_OFFSCREEN_CANVAS} from './has.js'; + /** * @module ol/dom */ +//FIXME Move this function to the canvas module /** * Create an html canvas element and returns its 2d context. * @param {number=} opt_width Canvas width. @@ -12,14 +15,18 @@ */ export function createCanvasContext2D(opt_width, opt_height, opt_canvasPool) { const canvas = opt_canvasPool && opt_canvasPool.length ? - opt_canvasPool.shift() : document.createElement('canvas'); + opt_canvasPool.shift() : + WORKER_OFFSCREEN_CANVAS ? + new OffscreenCanvas(opt_width || 300, opt_height || 300) : + document.createElement('canvas'); if (opt_width) { canvas.width = opt_width; } if (opt_height) { canvas.height = opt_height; } - return canvas.getContext('2d'); + //FIXME Allow OffscreenCanvasRenderingContext2D as return type + return /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d')); } diff --git a/src/ol/has.js b/src/ol/has.js index bfc8aabef3..6452b79a58 100644 --- a/src/ol/has.js +++ b/src/ol/has.js @@ -37,7 +37,15 @@ export const MAC = ua.indexOf('macintosh') !== -1; * @type {number} * @api */ -export const DEVICE_PIXEL_RATIO = window.devicePixelRatio || 1; +export const DEVICE_PIXEL_RATIO = typeof devicePixelRatio !== 'undefined' ? devicePixelRatio : 1; + +/** + * The execution context is a worker with OffscreenCanvas available. + * @const + * @type {boolean} + */ +export const WORKER_OFFSCREEN_CANVAS = typeof WorkerGlobalScope !== 'undefined' && typeof OffscreenCanvas !== 'undefined' && + self instanceof WorkerGlobalScope; //eslint-disable-line /** * Image.prototype.decode() is supported. diff --git a/src/ol/render/canvas.js b/src/ol/render/canvas.js index adb892dd34..edd5737a07 100644 --- a/src/ol/render/canvas.js +++ b/src/ol/render/canvas.js @@ -6,6 +6,8 @@ import {createCanvasContext2D} from '../dom.js'; import {clear} from '../obj.js'; import BaseObject from '../Object.js'; import EventTarget from '../events/Target.js'; +import {WORKER_OFFSCREEN_CANVAS} from '../has.js'; +import {toString} from '../transform.js'; /** @@ -297,34 +299,40 @@ export const measureTextHeight = (function() { */ let div; const heights = textHeights; - return function(font) { - let height = heights[font]; + return function(fontSpec) { + let height = heights[fontSpec]; if (height == undefined) { - if (!div) { - div = document.createElement('div'); - div.innerHTML = 'M'; - div.style.margin = '0 !important'; - div.style.padding = '0 !important'; - div.style.position = 'absolute !important'; - div.style.left = '-99999px !important'; + if (WORKER_OFFSCREEN_CANVAS) { + const font = getFontParameters(fontSpec); + const metrics = measureText(fontSpec, 'Žg'); + const lineHeight = isNaN(Number(font.lineHeight)) ? 1.2 : Number(font.lineHeight); + textHeights[fontSpec] = lineHeight * (metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent); + } else { + if (!div) { + div = document.createElement('div'); + div.innerHTML = 'M'; + div.style.margin = '0 !important'; + div.style.padding = '0 !important'; + div.style.position = 'absolute !important'; + div.style.left = '-99999px !important'; + } + div.style.font = fontSpec; + document.body.appendChild(div); + height = div.offsetHeight; + heights[fontSpec] = height; + document.body.removeChild(div); } - div.style.font = font; - document.body.appendChild(div); - height = div.offsetHeight; - heights[font] = height; - document.body.removeChild(div); } return height; }; })(); - /** * @param {string} font Font. * @param {string} text Text. - * @return {number} Width. + * @return {TextMetrics} Text metrics. */ -export function measureTextWidth(font, text) { +function measureText(font, text) { if (!measureContext) { measureContext = createCanvasContext2D(1, 1); } @@ -332,9 +340,17 @@ export function measureTextWidth(font, text) { measureContext.font = font; measureFont = measureContext.font; } - return measureContext.measureText(text).width; + return measureContext.measureText(text); } +/** + * @param {string} font Font. + * @param {string} text Text. + * @return {number} Width. + */ +export function measureTextWidth(font, text) { + return measureText(font, text).width; +} /** * Measure text width using a cache. @@ -432,9 +448,31 @@ function executeLabelInstructions(label, context) { const contextInstructions = label.contextInstructions; for (let i = 0, ii = contextInstructions.length; i < ii; i += 2) { if (Array.isArray(contextInstructions[i + 1])) { - CanvasRenderingContext2D.prototype[contextInstructions[i]].apply(context, contextInstructions[i + 1]); + context[contextInstructions[i]].apply(context, contextInstructions[i + 1]); } else { context[contextInstructions[i]] = contextInstructions[i + 1]; } } } + +/** + * @type {HTMLCanvasElement} + * @private + */ +let createTransformStringCanvas = null; + +/** + * @param {import("../transform.js").Transform} transform Transform. + * @return {string} CSS transform. + */ +export function createTransformString(transform) { + if (WORKER_OFFSCREEN_CANVAS) { + return toString(transform); + } else { + if (!createTransformStringCanvas) { + createTransformStringCanvas = createCanvasContext2D(1, 1).canvas; + } + createTransformStringCanvas.style.transform = toString(transform); + return createTransformStringCanvas.style.transform; + } +} diff --git a/src/ol/render/canvas/Executor.js b/src/ol/render/canvas/Executor.js index ccdf260033..5f88e0aa94 100644 --- a/src/ol/render/canvas/Executor.js +++ b/src/ol/render/canvas/Executor.js @@ -18,6 +18,7 @@ import { } from '../../transform.js'; import {defaultTextAlign, measureTextHeight, measureAndCacheTextWidth, measureTextWidths} from '../canvas.js'; import RBush from 'rbush/rbush.js'; +import {WORKER_OFFSCREEN_CANVAS} from '../../has.js'; /** @@ -204,7 +205,9 @@ class Executor { contextInstructions.push('lineCap', strokeState.lineCap); contextInstructions.push('lineJoin', strokeState.lineJoin); contextInstructions.push('miterLimit', strokeState.miterLimit); - if (CanvasRenderingContext2D.prototype.setLineDash) { + // eslint-disable-next-line + const Context = WORKER_OFFSCREEN_CANVAS ? OffscreenCanvasRenderingContext2D : CanvasRenderingContext2D; + if (Context.prototype.setLineDash) { contextInstructions.push('setLineDash', [strokeState.lineDash]); contextInstructions.push('lineDashOffset', strokeState.lineDashOffset); } diff --git a/src/ol/renderer/canvas/ImageLayer.js b/src/ol/renderer/canvas/ImageLayer.js index 7403890219..bd11ac74b3 100644 --- a/src/ol/renderer/canvas/ImageLayer.js +++ b/src/ol/renderer/canvas/ImageLayer.js @@ -8,6 +8,7 @@ import {fromUserExtent} from '../../proj.js'; import {getIntersection, isEmpty} from '../../extent.js'; import CanvasLayerRenderer from './Layer.js'; import {compose as composeTransform, makeInverse} from '../../transform.js'; +import {createTransformString} from '../../render/canvas.js'; /** * @classdesc @@ -109,7 +110,7 @@ class CanvasImageLayerRenderer extends CanvasLayerRenderer { ); makeInverse(this.inversePixelTransform, this.pixelTransform); - const canvasTransform = this.createTransformString(this.pixelTransform); + const canvasTransform = createTransformString(this.pixelTransform); this.useContainer(target, canvasTransform, layerState.opacity); diff --git a/src/ol/renderer/canvas/Layer.js b/src/ol/renderer/canvas/Layer.js index d082f13923..57338822b1 100644 --- a/src/ol/renderer/canvas/Layer.js +++ b/src/ol/renderer/canvas/Layer.js @@ -7,7 +7,7 @@ import RenderEvent from '../../render/Event.js'; import RenderEventType from '../../render/EventType.js'; import {rotateAtOffset} from '../../render/canvas.js'; import LayerRenderer from '../Layer.js'; -import {create as createTransform, apply as applyTransform, compose as composeTransform, toString} from '../../transform.js'; +import {create as createTransform, apply as applyTransform, compose as composeTransform} from '../../transform.js'; /** * @abstract @@ -69,12 +69,6 @@ class CanvasLayerRenderer extends LayerRenderer { */ this.containerReused = false; - /** - * @type {HTMLCanvasElement} - * @private - */ - this.createTransformStringCanvas_ = createCanvasContext2D(1, 1).canvas; - } /** @@ -269,15 +263,7 @@ class CanvasLayerRenderer extends LayerRenderer { return data; } - /** - * @param {import("../../transform.js").Transform} transform Transform. - * @return {string} CSS transform. - */ - createTransformString(transform) { - this.createTransformStringCanvas_.style.transform = toString(transform); - return this.createTransformStringCanvas_.style.transform; - } - } export default CanvasLayerRenderer; + diff --git a/src/ol/renderer/canvas/TileLayer.js b/src/ol/renderer/canvas/TileLayer.js index 51fb12939f..51a4d8a372 100644 --- a/src/ol/renderer/canvas/TileLayer.js +++ b/src/ol/renderer/canvas/TileLayer.js @@ -9,6 +9,7 @@ import {createEmpty, equals, getIntersection, getTopLeft} from '../../extent.js' import CanvasLayerRenderer from './Layer.js'; import {apply as applyTransform, compose as composeTransform, makeInverse} from '../../transform.js'; import {numberSafeCompareFunction} from '../../array.js'; +import {createTransformString} from '../../render/canvas.js'; /** * @classdesc @@ -243,7 +244,7 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer { -width / 2, -height / 2 ); - const canvasTransform = this.createTransformString(this.pixelTransform); + const canvasTransform = createTransformString(this.pixelTransform); this.useContainer(target, canvasTransform, layerState.opacity); const context = this.context; diff --git a/tsconfig.json b/tsconfig.json index 438785ff84..4a838e8614 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,9 +3,10 @@ /* Basic Options */ "target": "ES2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ - "lib": ["es2017", "dom"], /* Specify library files to be included in the compilation. */ + "lib": ["es2017", "dom", "webworker"], /* Specify library files to be included in the compilation. */ "allowJs": true, /* Allow javascript files to be compiled. */ "checkJs": true, /* Report errors in .js files. */ + "skipLibCheck": true, // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ // "declaration": true, /* Generates corresponding '.d.ts' file. */ // "sourceMap": true, /* Generates corresponding '.map' file. */