/** * @module ol/renderer/canvas/TileLayer */ import CanvasLayerRenderer from './Layer.js'; import ImageTile from '../../ImageTile.js'; import ReprojTile from '../../reproj/Tile.js'; import TileRange from '../../TileRange.js'; import TileState from '../../TileState.js'; import { apply as applyTransform, compose as composeTransform, makeInverse, toString as toTransformString, } from '../../transform.js'; import {ascending} from '../../array.js'; import { containsCoordinate, createEmpty, equals, getHeight, getIntersection, getRotatedViewport, getTopLeft, getWidth, intersects, } from '../../extent.js'; import {fromUserExtent} from '../../proj.js'; import {getUid} from '../../util.js'; import {toSize} from '../../size.js'; /** * @classdesc * Canvas renderer for tile layers. * @api * @template {import("../../layer/Tile.js").default|import("../../layer/VectorTile.js").default} [LayerType=import("../../layer/Tile.js").default|import("../../layer/VectorTile.js").default] * @extends {CanvasLayerRenderer} */ class CanvasTileLayerRenderer extends CanvasLayerRenderer { /** * @param {LayerType} tileLayer Tile layer. */ constructor(tileLayer) { super(tileLayer); /** * Rendered extent has changed since the previous `renderFrame()` call * @type {boolean} */ this.extentChanged = true; /** * @private * @type {?import("../../extent.js").Extent} */ this.renderedExtent_ = null; /** * @protected * @type {number} */ this.renderedPixelRatio; /** * @protected * @type {import("../../proj/Projection.js").default} */ this.renderedProjection = null; /** * @protected * @type {number} */ this.renderedRevision; /** * @protected * @type {!Array} */ this.renderedTiles = []; /** * @private * @type {boolean} */ this.newTiles_ = false; /** * @protected * @type {import("../../extent.js").Extent} */ this.tmpExtent = createEmpty(); /** * @private * @type {import("../../TileRange.js").default} */ this.tmpTileRange_ = new TileRange(0, 0, 0, 0); } /** * @protected * @param {import("../../Tile.js").default} tile Tile. * @return {boolean} Tile is drawable. */ isDrawableTile(tile) { const tileLayer = this.getLayer(); const tileState = tile.getState(); const useInterimTilesOnError = tileLayer.getUseInterimTilesOnError(); return ( tileState == TileState.LOADED || tileState == TileState.EMPTY || (tileState == TileState.ERROR && !useInterimTilesOnError) ); } /** * @param {number} z Tile coordinate z. * @param {number} x Tile coordinate x. * @param {number} y Tile coordinate y. * @param {import("../../Map.js").FrameState} frameState Frame state. * @return {!import("../../Tile.js").default} Tile. */ getTile(z, x, y, frameState) { const pixelRatio = frameState.pixelRatio; const projection = frameState.viewState.projection; const tileLayer = this.getLayer(); const tileSource = tileLayer.getSource(); let tile = tileSource.getTile(z, x, y, pixelRatio, projection); if (tile.getState() == TileState.ERROR) { if (tileLayer.getUseInterimTilesOnError() && tileLayer.getPreload() > 0) { // Preloaded tiles for lower resolutions might have finished loading. this.newTiles_ = true; } } if (!this.isDrawableTile(tile)) { tile = tile.getInterimTile(); } return tile; } /** * @param {import("../../pixel.js").Pixel} pixel Pixel. * @return {Uint8ClampedArray} Data at the pixel location. */ getData(pixel) { const frameState = this.frameState; if (!frameState) { return null; } const layer = this.getLayer(); const coordinate = applyTransform( frameState.pixelToCoordinateTransform, pixel.slice() ); const layerExtent = layer.getExtent(); if (layerExtent) { if (!containsCoordinate(layerExtent, coordinate)) { return null; } } const pixelRatio = frameState.pixelRatio; const projection = frameState.viewState.projection; const viewState = frameState.viewState; const source = layer.getRenderSource(); const tileGrid = source.getTileGridForProjection(viewState.projection); const tilePixelRatio = source.getTilePixelRatio(frameState.pixelRatio); for ( let z = tileGrid.getZForResolution(viewState.resolution); z >= tileGrid.getMinZoom(); --z ) { const tileCoord = tileGrid.getTileCoordForCoordAndZ(coordinate, z); const tile = source.getTile( z, tileCoord[1], tileCoord[2], pixelRatio, projection ); if ( !(tile instanceof ImageTile || tile instanceof ReprojTile) || (tile instanceof ReprojTile && tile.getState() === TileState.EMPTY) ) { return null; } if (tile.getState() !== TileState.LOADED) { continue; } const tileOrigin = tileGrid.getOrigin(z); const tileSize = toSize(tileGrid.getTileSize(z)); const tileResolution = tileGrid.getResolution(z); const col = Math.floor( tilePixelRatio * ((coordinate[0] - tileOrigin[0]) / tileResolution - tileCoord[1] * tileSize[0]) ); const row = Math.floor( tilePixelRatio * ((tileOrigin[1] - coordinate[1]) / tileResolution - tileCoord[2] * tileSize[1]) ); const gutter = Math.round( tilePixelRatio * source.getGutterForProjection(viewState.projection) ); return this.getImageData(tile.getImage(), col + gutter, row + gutter); } return null; } /** * @param {Object>} tiles Lookup of loaded tiles by zoom level. * @param {number} zoom Zoom level. * @param {import("../../Tile.js").default} tile Tile. * @return {boolean|void} If `false`, the tile will not be considered loaded. */ loadedTileCallback(tiles, zoom, tile) { if (this.isDrawableTile(tile)) { return super.loadedTileCallback(tiles, zoom, tile); } return false; } /** * Determine whether render should be called. * @param {import("../../Map.js").FrameState} frameState Frame state. * @return {boolean} Layer is ready to be rendered. */ prepareFrame(frameState) { return !!this.getLayer().getSource(); } /** * Render the layer. * @param {import("../../Map.js").FrameState} frameState Frame state. * @param {HTMLElement} target Target that may be used to render content to. * @return {HTMLElement} The rendered element. */ renderFrame(frameState, target) { const layerState = frameState.layerStatesArray[frameState.layerIndex]; const viewState = frameState.viewState; const projection = viewState.projection; const viewResolution = viewState.resolution; const viewCenter = viewState.center; const rotation = viewState.rotation; const pixelRatio = frameState.pixelRatio; const tileLayer = this.getLayer(); const tileSource = tileLayer.getSource(); const sourceRevision = tileSource.getRevision(); const tileGrid = tileSource.getTileGridForProjection(projection); const z = tileGrid.getZForResolution(viewResolution, tileSource.zDirection); 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) { extent = getIntersection( extent, fromUserExtent(layerState.extent, projection) ); } const dx = (tileResolution * width) / 2 / tilePixelRatio; const dy = (tileResolution * height) / 2 / tilePixelRatio; const canvasExtent = [ viewCenter[0] - dx, viewCenter[1] - dy, viewCenter[0] + dx, viewCenter[1] + dy, ]; const tileRange = tileGrid.getTileRangeForExtentAndZ(extent, z); /** * @type {Object>} */ const tilesToDrawByZ = {}; tilesToDrawByZ[z] = {}; const findLoadedTiles = this.createLoadedTileFinder( tileSource, projection, tilesToDrawByZ ); 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; 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.includes(tile)) ) { this.newTiles_ = true; } } if (tile.getAlpha(uid, frameState.time) === 1) { // don't look for alt tiles if alpha is 1 continue; } } const childTileRange = tileGrid.getTileCoordChildTileRange( tile.tileCoord, tmpTileRange, tmpExtent ); let covered = false; if (childTileRange) { covered = findLoadedTiles(z + 1, childTileRange); } if (!covered) { tileGrid.forEachTileCoordParentTileRange( tile.tileCoord, findLoadedTiles, tmpTileRange, tmpExtent ); } } } const canvasScale = ((tileResolution / viewResolution) * pixelRatio) / tilePixelRatio; // set forward and inverse pixel transforms composeTransform( this.pixelTransform, frameState.size[0] / 2, frameState.size[1] / 2, 1 / pixelRatio, 1 / pixelRatio, rotation, -width / 2, -height / 2 ); const canvasTransform = toTransformString(this.pixelTransform); this.useContainer(target, canvasTransform, this.getBackground(frameState)); const context = this.context; const canvas = context.canvas; makeInverse(this.inversePixelTransform, this.pixelTransform); // set scale transform for calculating tile positions on the canvas composeTransform( this.tempTransform, width / 2, height / 2, canvasScale, canvasScale, 0, -width / 2, -height / 2 ); if (canvas.width != width || canvas.height != height) { canvas.width = width; canvas.height = height; } else if (!this.containerReused) { context.clearRect(0, 0, width, height); } if (layerExtent) { this.clipUnrotated(context, frameState, layerExtent); } if (!tileSource.getInterpolate()) { context.imageSmoothingEnabled = false; } this.preRender(context, frameState); this.renderedTiles.length = 0; /** @type {Array} */ let zs = Object.keys(tilesToDrawByZ).map(Number); zs.sort(ascending); let clips, clipZs, currentClip; if ( layerState.opacity === 1 && (!this.containerReused || tileSource.getOpaque(frameState.viewState.projection)) ) { zs = zs.reverse(); } else { clips = []; clipZs = []; } for (let i = zs.length - 1; i >= 0; --i) { const currentZ = zs[i]; const currentTilePixelSize = tileSource.getTilePixelSize( currentZ, pixelRatio, projection ); const currentResolution = tileGrid.getResolution(currentZ); const currentScale = currentResolution / tileResolution; const dx = currentTilePixelSize[0] * currentScale * canvasScale; const dy = currentTilePixelSize[1] * currentScale * canvasScale; const originTileCoord = tileGrid.getTileCoordForCoordAndZ( getTopLeft(canvasExtent), currentZ ); const originTileExtent = tileGrid.getTileCoordExtent(originTileCoord); const origin = applyTransform(this.tempTransform, [ (tilePixelRatio * (originTileExtent[0] - canvasExtent[0])) / tileResolution, (tilePixelRatio * (canvasExtent[3] - originTileExtent[3])) / tileResolution, ]); const tileGutter = tilePixelRatio * tileSource.getGutterForProjection(projection); const tilesToDraw = tilesToDrawByZ[currentZ]; for (const tileCoordKey in tilesToDraw) { const tile = /** @type {import("../../ImageTile.js").default} */ ( tilesToDraw[tileCoordKey] ); const tileCoord = tile.tileCoord; // Calculate integer positions and sizes so that tiles align const xIndex = originTileCoord[1] - tileCoord[1]; const nextX = Math.round(origin[0] - (xIndex - 1) * dx); const yIndex = originTileCoord[2] - tileCoord[2]; const nextY = Math.round(origin[1] - (yIndex - 1) * dy); const x = Math.round(origin[0] - xIndex * dx); const y = Math.round(origin[1] - yIndex * dy); const w = nextX - x; const h = nextY - y; const transition = z === currentZ; const inTransition = transition && tile.getAlpha(getUid(this), frameState.time) !== 1; let contextSaved = false; if (!inTransition) { if (clips) { // Clip mask for regions in this tile that already filled by a higher z tile currentClip = [x, y, x + w, y, x + w, y + h, x, y + h]; for (let i = 0, ii = clips.length; i < ii; ++i) { if (z !== currentZ && currentZ < clipZs[i]) { const clip = clips[i]; if ( intersects( [x, y, x + w, y + h], [clip[0], clip[3], clip[4], clip[7]] ) ) { if (!contextSaved) { context.save(); contextSaved = true; } context.beginPath(); // counter-clockwise (outer ring) for current tile context.moveTo(currentClip[0], currentClip[1]); context.lineTo(currentClip[2], currentClip[3]); context.lineTo(currentClip[4], currentClip[5]); context.lineTo(currentClip[6], currentClip[7]); // clockwise (inner ring) for higher z tile context.moveTo(clip[6], clip[7]); context.lineTo(clip[4], clip[5]); context.lineTo(clip[2], clip[3]); context.lineTo(clip[0], clip[1]); context.clip(); } } } clips.push(currentClip); clipZs.push(currentZ); } else { context.clearRect(x, y, w, h); } } this.drawTileImage( tile, frameState, x, y, w, h, tileGutter, transition ); if (clips && !inTransition) { if (contextSaved) { context.restore(); } this.renderedTiles.unshift(tile); } else { this.renderedTiles.push(tile); } this.updateUsedTiles(frameState.usedTiles, tileSource, tile); } } this.renderedRevision = sourceRevision; this.renderedResolution = tileResolution; this.extentChanged = !this.renderedExtent_ || !equals(this.renderedExtent_, canvasExtent); this.renderedExtent_ = canvasExtent; this.renderedPixelRatio = pixelRatio; this.renderedProjection = projection; this.manageTilePyramid( frameState, tileSource, tileGrid, pixelRatio, projection, extent, z, tileLayer.getPreload() ); this.scheduleExpireCache(frameState, tileSource); this.postRender(context, frameState); if (layerState.extent) { context.restore(); } context.imageSmoothingEnabled = true; if (canvasTransform !== canvas.style.transform) { canvas.style.transform = canvasTransform; } return this.container; } /** * @param {import("../../ImageTile.js").default} tile Tile. * @param {import("../../Map.js").FrameState} frameState Frame state. * @param {number} x Left of the tile. * @param {number} y Top of the tile. * @param {number} w Width of the tile. * @param {number} h Height of the tile. * @param {number} gutter Tile gutter. * @param {boolean} transition Apply an alpha transition. */ drawTileImage(tile, frameState, x, y, w, h, gutter, transition) { const image = this.getTileImage(tile); if (!image) { return; } const uid = getUid(this); 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(); this.context.globalAlpha = alpha; } this.context.drawImage( image, gutter, gutter, image.width - 2 * gutter, image.height - 2 * gutter, x, y, w, h ); if (alphaChanged) { this.context.restore(); } if (alpha !== layerState.opacity) { frameState.animate = true; } else if (transition) { tile.endTransition(uid); } } /** * @return {HTMLCanvasElement} Image */ getImage() { const context = this.context; return context ? context.canvas : null; } /** * Get the image from a tile. * @param {import("../../ImageTile.js").default} tile Tile. * @return {HTMLCanvasElement|HTMLImageElement|HTMLVideoElement} Image. * @protected */ getTileImage(tile) { return tile.getImage(); } /** * @param {import("../../Map.js").FrameState} frameState Frame state. * @param {import("../../source/Tile.js").default} tileSource Tile source. * @protected */ scheduleExpireCache(frameState, tileSource) { if (tileSource.canExpireCache()) { /** * @param {import("../../source/Tile.js").default} tileSource Tile source. * @param {import("../../Map.js").default} map Map. * @param {import("../../Map.js").FrameState} frameState Frame state. */ const postRenderFunction = function (tileSource, map, frameState) { const tileSourceKey = getUid(tileSource); if (tileSourceKey in frameState.usedTiles) { tileSource.expireCache( frameState.viewState.projection, frameState.usedTiles[tileSourceKey] ); } }.bind(null, tileSource); frameState.postRenderFunctions.push( /** @type {import("../../Map.js").PostRenderFunction} */ ( postRenderFunction ) ); } } /** * @param {!Object>} usedTiles Used tiles. * @param {import("../../source/Tile.js").default} tileSource Tile source. * @param {import('../../Tile.js').default} tile Tile. * @protected */ updateUsedTiles(usedTiles, tileSource, tile) { // FIXME should we use tilesToDrawByZ instead? const tileSourceKey = getUid(tileSource); if (!(tileSourceKey in usedTiles)) { usedTiles[tileSourceKey] = {}; } usedTiles[tileSourceKey][tile.getKey()] = true; } /** * Manage tile pyramid. * This function performs a number of functions related to the tiles at the * current zoom and lower zoom levels: * - registers idle tiles in frameState.wantedTiles so that they are not * discarded by the tile queue * - enqueues missing tiles * @param {import("../../Map.js").FrameState} frameState Frame state. * @param {import("../../source/Tile.js").default} tileSource Tile source. * @param {import("../../tilegrid/TileGrid.js").default} tileGrid Tile grid. * @param {number} pixelRatio Pixel ratio. * @param {import("../../proj/Projection.js").default} projection Projection. * @param {import("../../extent.js").Extent} extent Extent. * @param {number} currentZ Current Z. * @param {number} preload Load low resolution tiles up to `preload` levels. * @param {function(import("../../Tile.js").default):void} [tileCallback] Tile callback. * @protected */ manageTilePyramid( frameState, tileSource, tileGrid, pixelRatio, projection, extent, currentZ, preload, tileCallback ) { const tileSourceKey = getUid(tileSource); if (!(tileSourceKey in frameState.wantedTiles)) { frameState.wantedTiles[tileSourceKey] = {}; } 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) { tileRange = tileGrid.getTileRangeForExtentAndZ(extent, z, tileRange); 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); if (tile.getState() == TileState.IDLE) { wantedTiles[tile.getKey()] = true; if (!tileQueue.isKeyQueued(tile.getKey())) { tileQueue.enqueue([ tile, tileSourceKey, tileGrid.getTileCoordCenter(tile.tileCoord), tileResolution, ]); } } if (tileCallback !== undefined) { tileCallback(tile); } } else { tileSource.useTile(z, x, y, projection); } } } } tileSource.updateCacheSize(tileCount, projection); } } export default CanvasTileLayerRenderer;