Files
openlayers/src/ol/renderer/canvas/TileLayer.js
2022-07-28 13:23:16 -06:00

769 lines
23 KiB
JavaScript

/**
* @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 {IMAGE_SMOOTHING_DISABLED, IMAGE_SMOOTHING_ENABLED} from './common.js';
import {
apply as applyTransform,
compose as composeTransform,
makeInverse,
toString as toTransformString,
} from '../../transform.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 {numberSafeCompareFunction} from '../../array.js';
import {toSize} from '../../size.js';
/**
* @classdesc
* Canvas renderer for tile layers.
* @api
* @template {import("../../layer/Tile.js").default<import("../../source/Tile.js").default>|import("../../layer/VectorTile.js").default} [LayerType=import("../../layer/Tile.js").default<import("../../source/Tile.js").default>|import("../../layer/VectorTile.js").default]
* @extends {CanvasLayerRenderer<LayerType>}
*/
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<import("../../Tile.js").default>}
*/
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("../../PluggableMap.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)) {
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<number, Object<string, import("../../Tile.js").default>>} 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("../../PluggableMap.js").FrameState} frameState Frame state.
* @return {boolean} Layer is ready to be rendered.
*/
prepareFrame(frameState) {
return !!this.getLayer().getSource();
}
/**
* Render the layer.
* @param {import("../../PluggableMap.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<number, Object<string, import("../../Tile.js").default>>}
*/
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.indexOf(tile) === -1)
) {
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()) {
Object.assign(context, IMAGE_SMOOTHING_DISABLED);
}
this.preRender(context, frameState);
this.renderedTiles.length = 0;
/** @type {Array<number>} */
let zs = Object.keys(tilesToDrawByZ).map(Number);
zs.sort(numberSafeCompareFunction);
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();
}
Object.assign(context, IMAGE_SMOOTHING_ENABLED);
if (canvasTransform !== canvas.style.transform) {
canvas.style.transform = canvasTransform;
}
return this.container;
}
/**
* @param {import("../../ImageTile.js").default} tile Tile.
* @param {import("../../PluggableMap.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("../../PluggableMap.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("../../PluggableMap.js").default} map Map.
* @param {import("../../PluggableMap.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("../../PluggableMap.js").PostRenderFunction} */ (
postRenderFunction
)
);
}
}
/**
* @param {!Object<string, !Object<string, boolean>>} 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("../../PluggableMap.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} [opt_tileCallback] Tile callback.
* @protected
*/
manageTilePyramid(
frameState,
tileSource,
tileGrid,
pixelRatio,
projection,
extent,
currentZ,
preload,
opt_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 (opt_tileCallback !== undefined) {
opt_tileCallback(tile);
}
} else {
tileSource.useTile(z, x, y, projection);
}
}
}
}
tileSource.updateCacheSize(tileCount, projection);
}
}
export default CanvasTileLayerRenderer;