diff --git a/src/ol/VectorRenderTile.js b/src/ol/VectorRenderTile.js index de6ac25d2e..087105a95f 100644 --- a/src/ol/VectorRenderTile.js +++ b/src/ol/VectorRenderTile.js @@ -2,7 +2,7 @@ * @module ol/VectorRenderTile */ import Tile from './Tile.js'; -import {createCanvasContext2D} from './dom.js'; +import {createCanvasContext2D, releaseCanvas} from './dom.js'; import {getUid} from './util.js'; /** @@ -154,7 +154,9 @@ class VectorRenderTile extends Tile { */ release() { for (const key in this.context_) { - canvasPool.push(this.context_[key].canvas); + const context = this.context_[key]; + releaseCanvas(context); + canvasPool.push(context.canvas); delete this.context_[key]; } super.release(); diff --git a/src/ol/css.js b/src/ol/css.js index 0e5b6e709d..be8954e834 100644 --- a/src/ol/css.js +++ b/src/ol/css.js @@ -114,11 +114,3 @@ export const getFontParameters = function (fontSpec) { style.families = style.family.split(/,\s?/); return style; }; - -/** - * @param {number} opacity Opacity (0..1). - * @return {string} CSS opacity. - */ -export function cssOpacity(opacity) { - return opacity === 1 ? '' : String(Math.round(opacity * 100) / 100); -} diff --git a/src/ol/dom.js b/src/ol/dom.js index 84f68d9fe9..3aa54d753b 100644 --- a/src/ol/dom.js +++ b/src/ol/dom.js @@ -40,6 +40,18 @@ export function createCanvasContext2D( ); } +/** + * Releases canvas memory to avoid exceeding memory limits in Safari. + * See https://pqina.nl/blog/total-canvas-memory-use-exceeds-the-maximum-limit/ + * @param {CanvasRenderingContext2D} context Context. + */ +export function releaseCanvas(context) { + const canvas = context.canvas; + canvas.width = 1; + canvas.height = 1; + context.clearRect(0, 0, 1, 1); +} + /** * Get the current computed width for the given element including margin, * padding and border. diff --git a/src/ol/extent.js b/src/ol/extent.js index c073fb3191..e24f791000 100644 --- a/src/ol/extent.js +++ b/src/ol/extent.js @@ -535,6 +535,29 @@ export function getForViewAndSize( size, opt_extent ) { + const [x0, y0, x1, y1, x2, y2, x3, y3] = getRotatedViewport( + center, + resolution, + rotation, + size + ); + return createOrUpdate( + Math.min(x0, x1, x2, x3), + Math.min(y0, y1, y2, y3), + Math.max(x0, x1, x2, x3), + Math.max(y0, y1, y2, y3), + opt_extent + ); +} + +/** + * @param {import("./coordinate.js").Coordinate} center Center. + * @param {number} resolution Resolution. + * @param {number} rotation Rotation. + * @param {import("./size.js").Size} size Size. + * @return {Array} Linear ring representing the viewport. + */ +export function getRotatedViewport(center, resolution, rotation, size) { const dx = (resolution * size[0]) / 2; const dy = (resolution * size[1]) / 2; const cosRotation = Math.cos(rotation); @@ -545,21 +568,18 @@ export function getForViewAndSize( const ySin = dy * sinRotation; const x = center[0]; const y = center[1]; - const x0 = x - xCos + ySin; - const x1 = x - xCos - ySin; - const x2 = x + xCos - ySin; - const x3 = x + xCos + ySin; - const y0 = y - xSin - yCos; - const y1 = y - xSin + yCos; - const y2 = y + xSin + yCos; - const y3 = y + xSin - yCos; - return createOrUpdate( - Math.min(x0, x1, x2, x3), - Math.min(y0, y1, y2, y3), - Math.max(x0, x1, x2, x3), - Math.max(y0, y1, y2, y3), - opt_extent - ); + return [ + x - xCos + ySin, + y - xSin - yCos, + x - xCos - ySin, + y - xSin + yCos, + x + xCos - ySin, + y + xSin + yCos, + x + xCos + ySin, + y + xSin - yCos, + x - xCos + ySin, + y - xSin - yCos, + ]; } /** diff --git a/src/ol/renderer/canvas/ImageLayer.js b/src/ol/renderer/canvas/ImageLayer.js index aec92d876d..4e8bad601e 100644 --- a/src/ol/renderer/canvas/ImageLayer.js +++ b/src/ol/renderer/canvas/ImageLayer.js @@ -168,18 +168,15 @@ class CanvasImageLayerRenderer extends CanvasLayerRenderer { const viewState = frameState.viewState; const viewCenter = viewState.center; const viewResolution = viewState.resolution; - const size = frameState.size; const scale = (pixelRatio * imageResolution) / (viewResolution * imagePixelRatio); - let width = Math.round(size[0] * pixelRatio); - let height = Math.round(size[1] * pixelRatio); + const extent = frameState.extent; + const resolution = viewState.resolution; const rotation = viewState.rotation; - if (rotation) { - const size = Math.round(Math.sqrt(width * width + height * height)); - width = size; - height = size; - } + // desired dimensions of the canvas in pixels + const width = Math.round((getWidth(extent) / resolution) * pixelRatio); + const height = Math.round((getHeight(extent) / resolution) * pixelRatio); // set forward and inverse pixel transforms composeTransform( @@ -196,12 +193,7 @@ class CanvasImageLayerRenderer extends CanvasLayerRenderer { const canvasTransform = toTransformString(this.pixelTransform); - this.useContainer( - target, - canvasTransform, - layerState.opacity, - this.getBackground(frameState) - ); + this.useContainer(target, canvasTransform, this.getBackground(frameState)); const context = this.context; const canvas = context.canvas; @@ -260,17 +252,7 @@ class CanvasImageLayerRenderer extends CanvasLayerRenderer { previousAlpha = context.globalAlpha; context.globalAlpha = opacity; } - context.drawImage( - img, - 0, - 0, - +img.width, - +img.height, - Math.round(dx), - Math.round(dy), - Math.round(dw), - Math.round(dh) - ); + context.drawImage(img, 0, 0, +img.width, +img.height, dx, dy, dw, dh); if (opacity !== 1) { context.globalAlpha = previousAlpha; } diff --git a/src/ol/renderer/canvas/Layer.js b/src/ol/renderer/canvas/Layer.js index 99e69b5e36..7852bcf6cb 100644 --- a/src/ol/renderer/canvas/Layer.js +++ b/src/ol/renderer/canvas/Layer.js @@ -20,6 +20,11 @@ import { import {createCanvasContext2D} from '../../dom.js'; import {equals} from '../../array.js'; +/** + * @type {Array} + */ +export const canvasPool = []; + /** * @type {CanvasRenderingContext2D} */ @@ -143,17 +148,14 @@ class CanvasLayerRenderer extends LayerRenderer { * Get a rendering container from an existing target, if compatible. * @param {HTMLElement} target Potential render target. * @param {string} transform CSS Transform. - * @param {number} opacity Opacity. * @param {string} [opt_backgroundColor] Background color. */ - useContainer(target, transform, opacity, opt_backgroundColor) { + useContainer(target, transform, opt_backgroundColor) { const layerClassName = this.getLayer().getClassName(); let container, context; if ( target && target.className === layerClassName && - target.style.opacity === '' && - opacity === 1 && (!opt_backgroundColor || (target && target.style.backgroundColor && diff --git a/src/ol/renderer/canvas/TileLayer.js b/src/ol/renderer/canvas/TileLayer.js index eb8c65896b..5d0695aaaa 100644 --- a/src/ol/renderer/canvas/TileLayer.js +++ b/src/ol/renderer/canvas/TileLayer.js @@ -18,11 +18,13 @@ import { containsCoordinate, createEmpty, equals, + getHeight, getIntersection, + getRotatedViewport, getTopLeft, + getWidth, intersects, } from '../../extent.js'; -import {cssOpacity} from '../../css.js'; import {fromUserExtent} from '../../proj.js'; import {getUid} from '../../util.js'; import {numberSafeCompareFunction} from '../../array.js'; @@ -263,6 +265,12 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer { const tileResolution = tileGrid.getResolution(z); let extent = frameState.extent; + const resolution = frameState.viewState.resolution; + const tilePixelRatio = tileSource.getTilePixelRatio(pixelRatio); + // desired dimensions of the canvas in pixels + const width = Math.round((getWidth(extent) / resolution) * pixelRatio); + const height = Math.round((getHeight(extent) / resolution) * pixelRatio); + const layerExtent = layerState.extent && fromUserExtent(layerState.extent, projection); if (layerExtent) { @@ -272,18 +280,6 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer { ); } - const tilePixelRatio = tileSource.getTilePixelRatio(pixelRatio); - - // desired dimensions of the canvas in pixels - let width = Math.round(frameState.size[0] * tilePixelRatio); - let height = Math.round(frameState.size[1] * tilePixelRatio); - - if (rotation) { - const size = Math.round(Math.sqrt(width * width + height * height)); - width = size; - height = size; - } - const dx = (tileResolution * width) / 2 / tilePixelRatio; const dy = (tileResolution * height) / 2 / tilePixelRatio; const canvasExtent = [ @@ -310,14 +306,33 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer { const tmpExtent = this.tmpExtent; const tmpTileRange = this.tmpTileRange_; this.newTiles_ = false; + const viewport = rotation + ? getRotatedViewport( + viewState.center, + resolution, + rotation, + frameState.size + ) + : undefined; for (let x = tileRange.minX; x <= tileRange.maxX; ++x) { for (let y = tileRange.minY; y <= tileRange.maxY; ++y) { + if ( + rotation && + !tileGrid.tileCoordIntersectsViewport([z, x, y], viewport) + ) { + continue; + } const tile = this.getTile(z, x, y, frameState); if (this.isDrawableTile(tile)) { const uid = getUid(this); if (tile.getState() == TileState.LOADED) { tilesToDrawByZ[z][tile.tileCoord.toString()] = tile; - const inTransition = tile.inTransition(uid); + let inTransition = tile.inTransition(uid); + if (inTransition && layerState.opacity !== 1) { + // Skipping transition when layer is not fully opaque avoids visual artifacts. + tile.endTransition(uid); + inTransition = false; + } if ( !this.newTiles_ && (inTransition || this.renderedTiles.indexOf(tile) === -1) @@ -352,15 +367,16 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer { } } - const canvasScale = tileResolution / viewResolution; + const canvasScale = + ((tileResolution / viewResolution) * pixelRatio) / tilePixelRatio; // set forward and inverse pixel transforms composeTransform( this.pixelTransform, frameState.size[0] / 2, frameState.size[1] / 2, - 1 / tilePixelRatio, - 1 / tilePixelRatio, + 1 / pixelRatio, + 1 / pixelRatio, rotation, -width / 2, -height / 2 @@ -368,12 +384,7 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer { const canvasTransform = toTransformString(this.pixelTransform); - this.useContainer( - target, - canvasTransform, - layerState.opacity, - this.getBackground(frameState) - ); + this.useContainer(target, canvasTransform, this.getBackground(frameState)); const context = this.context; const canvas = context.canvas; @@ -559,11 +570,6 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer { if (canvasTransform !== canvas.style.transform) { canvas.style.transform = canvasTransform; } - const opacity = cssOpacity(layerState.opacity); - const container = this.container; - if (opacity !== container.style.opacity) { - container.style.opacity = opacity; - } return this.container; } @@ -584,7 +590,10 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer { return; } const uid = getUid(this); - const alpha = transition ? tile.getAlpha(uid, frameState.time) : 1; + const layerState = frameState.layerStatesArray[frameState.layerIndex]; + const alpha = + layerState.opacity * + (transition ? tile.getAlpha(uid, frameState.time) : 1); const alphaChanged = alpha !== this.context.globalAlpha; if (alphaChanged) { this.context.save(); @@ -605,7 +614,7 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer { if (alphaChanged) { this.context.restore(); } - if (alpha !== 1) { + if (alpha !== layerState.opacity) { frameState.animate = true; } else if (transition) { tile.endTransition(uid); @@ -711,6 +720,15 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer { const wantedTiles = frameState.wantedTiles[tileSourceKey]; const tileQueue = frameState.tileQueue; const minZoom = tileGrid.getMinZoom(); + const rotation = frameState.viewState.rotation; + const viewport = rotation + ? getRotatedViewport( + frameState.viewState.center, + frameState.viewState.resolution, + rotation, + frameState.size + ) + : undefined; let tileCount = 0; let tile, tileRange, tileResolution, x, y, z; for (z = minZoom; z <= currentZ; ++z) { @@ -718,6 +736,12 @@ class CanvasTileLayerRenderer extends CanvasLayerRenderer { tileResolution = tileGrid.getResolution(z); for (x = tileRange.minX; x <= tileRange.maxX; ++x) { for (y = tileRange.minY; y <= tileRange.maxY; ++y) { + if ( + rotation && + !tileGrid.tileCoordIntersectsViewport([z, x, y], viewport) + ) { + continue; + } if (currentZ - z <= preload) { ++tileCount; tile = tileSource.getTile(z, x, y, pixelRatio, projection); diff --git a/src/ol/renderer/canvas/VectorImageLayer.js b/src/ol/renderer/canvas/VectorImageLayer.js index 57071f4ecc..75948f710c 100644 --- a/src/ol/renderer/canvas/VectorImageLayer.js +++ b/src/ol/renderer/canvas/VectorImageLayer.js @@ -105,8 +105,11 @@ class CanvasVectorImageLayerRenderer extends CanvasImageLayerRenderer { !hints[ViewHint.INTERACTING] && !isEmpty(renderedExtent) ) { - vectorRenderer.useContainer(null, null, 1); + vectorRenderer.useContainer(null, null); const context = vectorRenderer.context; + const layerState = frameState.layerStatesArray[frameState.layerIndex]; + context.globalAlpha = layerState.opacity; + const imageLayerState = assign({}, layerState, {opacity: 1}); const imageFrameState = /** @type {import("../../PluggableMap.js").FrameState} */ ( assign({}, frameState, { @@ -118,6 +121,8 @@ class CanvasVectorImageLayerRenderer extends CanvasImageLayerRenderer { rotation: 0, }) ), + layerStatesArray: [imageLayerState], + layerIndex: 0, }) ); let emptyImage = true; diff --git a/src/ol/renderer/canvas/VectorLayer.js b/src/ol/renderer/canvas/VectorLayer.js index 1e495ed189..da5e41e56f 100644 --- a/src/ol/renderer/canvas/VectorLayer.js +++ b/src/ol/renderer/canvas/VectorLayer.js @@ -2,7 +2,7 @@ * @module ol/renderer/canvas/VectorLayer */ import CanvasBuilderGroup from '../../render/canvas/BuilderGroup.js'; -import CanvasLayerRenderer from './Layer.js'; +import CanvasLayerRenderer, {canvasPool} from './Layer.js'; import ExecutorGroup from '../../render/canvas/ExecutorGroup.js'; import ViewHint from '../../ViewHint.js'; import { @@ -24,7 +24,7 @@ import { intersects as intersectsExtent, wrapX as wrapExtentX, } from '../../extent.js'; -import {cssOpacity} from '../../css.js'; +import {createCanvasContext2D, releaseCanvas} from '../../dom.js'; import { defaultOrder as defaultRenderOrder, getTolerance as getRenderTolerance, @@ -142,6 +142,18 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { * @type {boolean} */ this.clipping = true; + + /** + * @private + * @type {CanvasRenderingContext2D} + */ + this.compositionContext_ = null; + + /** + * @private + * @type {number} + */ + this.opacity_ = 1; } /** @@ -163,7 +175,7 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { const snapToPixel = !( viewHints[ViewHint.ANIMATING] || viewHints[ViewHint.INTERACTING] ); - const context = this.context; + const context = this.compositionContext_; const width = Math.round(frameState.size[0] * pixelRatio); const height = Math.round(frameState.size[1] * pixelRatio); @@ -197,17 +209,44 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { } while (++world < endWorld); } + setupCompositionContext_() { + if (this.opacity_ !== 1) { + const compositionContext = createCanvasContext2D( + this.context.canvas.width, + this.context.canvas.height, + canvasPool + ); + this.compositionContext_ = compositionContext; + } else { + this.compositionContext_ = this.context; + } + } + + releaseCompositionContext_() { + if (this.opacity_ !== 1) { + const alpha = this.context.globalAlpha; + this.context.globalAlpha = this.opacity_; + this.context.drawImage(this.compositionContext_.canvas, 0, 0); + this.context.globalAlpha = alpha; + releaseCanvas(this.compositionContext_); + canvasPool.push(this.compositionContext_.canvas); + this.compositionContext_ = null; + } + } + /** * Render declutter items for this layer * @param {import("../../PluggableMap.js").FrameState} frameState Frame state. */ renderDeclutter(frameState) { if (this.declutterExecutorGroup) { + this.setupCompositionContext_(); this.renderWorlds( this.declutterExecutorGroup, frameState, frameState.declutterTree ); + this.releaseCompositionContext_(); } } @@ -227,12 +266,7 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { const canvasTransform = transformToString(this.pixelTransform); - this.useContainer( - target, - canvasTransform, - layerState.opacity, - this.getBackground(frameState) - ); + this.useContainer(target, canvasTransform, this.getBackground(frameState)); const context = this.context; const canvas = context.canvas; @@ -263,6 +297,9 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { const viewState = frameState.viewState; const projection = viewState.projection; + this.opacity_ = layerState.opacity; + this.setupCompositionContext_(); + // clipped rendering if layer extent is set let clipped = false; let render = true; @@ -271,7 +308,7 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { render = intersectsExtent(layerExtent, frameState.extent); clipped = render && !containsExtent(layerExtent, frameState.extent); if (clipped) { - this.clipUnrotated(context, frameState, layerExtent); + this.clipUnrotated(this.compositionContext_, frameState, layerExtent); } } @@ -280,17 +317,13 @@ class CanvasVectorLayerRenderer extends CanvasLayerRenderer { } if (clipped) { - context.restore(); + this.compositionContext_.restore(); } + this.releaseCompositionContext_(); + this.postRender(context, frameState); - const opacity = cssOpacity(layerState.opacity); - const container = this.container; - if (opacity !== container.style.opacity) { - container.style.opacity = opacity; - } - if (this.renderedRotation_ !== viewState.rotation) { this.renderedRotation_ = viewState.rotation; this.hitDetectionImageData_ = null; diff --git a/src/ol/reproj.js b/src/ol/reproj.js index 4472f76fc2..06b427b3d0 100644 --- a/src/ol/reproj.js +++ b/src/ol/reproj.js @@ -19,6 +19,11 @@ import {solveLinearSystem} from './math.js'; let brokenDiagonalRendering_; +/** + * @type {Array} + */ +export const canvasPool = []; + /** * This draws a small triangle into a canvas by setting the triangle as the clip region * and then drawing a (too large) rectangle @@ -217,7 +222,8 @@ export function render( ) { const context = createCanvasContext2D( Math.round(pixelRatio * width), - Math.round(pixelRatio * height) + Math.round(pixelRatio * height), + canvasPool ); if (!opt_interpolate) { diff --git a/src/ol/reproj/Tile.js b/src/ol/reproj/Tile.js index 1be2fb2d28..78fd235b5e 100644 --- a/src/ol/reproj/Tile.js +++ b/src/ol/reproj/Tile.js @@ -9,11 +9,13 @@ import TileState from '../TileState.js'; import Triangulation from './Triangulation.js'; import { calculateSourceExtentResolution, + canvasPool, render as renderReprojected, } from '../reproj.js'; import {clamp} from '../math.js'; import {getArea, getIntersection} from '../extent.js'; import {listen, unlistenByKey} from '../events.js'; +import {releaseCanvas} from '../dom.js'; /** * @typedef {function(number, number, number, number) : import("../Tile.js").default} FunctionType @@ -349,6 +351,18 @@ class ReprojTile extends Tile { this.sourcesListenerKeys_.forEach(unlistenByKey); this.sourcesListenerKeys_ = null; } + + /** + * Remove from the cache due to expiry + */ + release() { + if (this.canvas_) { + releaseCanvas(this.canvas_.getContext('2d')); + canvasPool.push(this.canvas_); + this.canvas_ = null; + } + super.release(); + } } export default ReprojTile; diff --git a/src/ol/tilegrid/TileGrid.js b/src/ol/tilegrid/TileGrid.js index fb5be9a3b5..a4f6f6dde8 100644 --- a/src/ol/tilegrid/TileGrid.js +++ b/src/ol/tilegrid/TileGrid.js @@ -9,6 +9,7 @@ import {assert} from '../asserts.js'; import {ceil, clamp, floor} from '../math.js'; import {createOrUpdate, getTopLeft} from '../extent.js'; import {createOrUpdate as createOrUpdateTileCoord} from '../tilecoord.js'; +import {intersectsLinearRing} from '../geom/flat/intersectsextent.js'; import {isSorted, linearFindNearest} from '../array.js'; import {toSize} from '../size.js'; @@ -656,6 +657,22 @@ class TileGrid { return clamp(z, this.minZoom, this.maxZoom); } + /** + * The tile with the provided tile coordinate intersects the given viewport. + * @param {import('../tilecoord.js').TileCoord} tileCoord Tile coordinate. + * @param {Array} viewport Viewport as returned from {@link module:ol/extent.getRotatedViewport}. + * @return {boolean} The tile with the provided tile coordinate intersects the given viewport. + */ + tileCoordIntersectsViewport(tileCoord, viewport) { + return intersectsLinearRing( + viewport, + 0, + viewport.length, + 2, + this.getTileCoordExtent(tileCoord) + ); + } + /** * @param {!import("../extent.js").Extent} extent Extent for this tile grid. * @private diff --git a/test/node/ol/css.test.js b/test/node/ol/css.test.js index 05d441ee98..90c51d572e 100644 --- a/test/node/ol/css.test.js +++ b/test/node/ol/css.test.js @@ -1,13 +1,7 @@ import expect from '../expect.js'; -import {cssOpacity, getFontParameters} from '../../../src/ol/css.js'; +import {getFontParameters} from '../../../src/ol/css.js'; describe('ol.css', function () { - describe('cssOpacity()', function () { - it('converts number to string, 1 to ""', function () { - expect(cssOpacity(0.5)).to.eql('0.5'); - expect(cssOpacity(1)).to.eql(''); - }); - }); describe('getFontParameters()', function () { const cases = [ { diff --git a/test/rendering/cases/image-no-stretch-interpolate-false/expected.png b/test/rendering/cases/image-no-stretch-interpolate-false/expected.png index 65990bfdce..08ca31953e 100644 Binary files a/test/rendering/cases/image-no-stretch-interpolate-false/expected.png and b/test/rendering/cases/image-no-stretch-interpolate-false/expected.png differ diff --git a/test/rendering/cases/image-stretched-interpolate-false/expected.png b/test/rendering/cases/image-stretched-interpolate-false/expected.png index 8121f09e86..1b30265d04 100644 Binary files a/test/rendering/cases/image-stretched-interpolate-false/expected.png and b/test/rendering/cases/image-stretched-interpolate-false/expected.png differ diff --git a/test/rendering/cases/layer-image/expected.png b/test/rendering/cases/layer-image/expected.png index abe61291b4..d167008af0 100644 Binary files a/test/rendering/cases/layer-image/expected.png and b/test/rendering/cases/layer-image/expected.png differ diff --git a/test/rendering/cases/postrender-immediate/expected.png b/test/rendering/cases/postrender-immediate/expected.png index a260603a5e..fd0a748da5 100644 Binary files a/test/rendering/cases/postrender-immediate/expected.png and b/test/rendering/cases/postrender-immediate/expected.png differ