/** * @module ol/render/canvas/ExecutorGroup */ import {ascending, descending} from '../../array.js'; import {createCanvasContext2D} from '../../dom.js'; import {buffer, createEmpty, extendCoordinate} from '../../extent.js'; import {transform2D} from '../../geom/flat/transform.js'; import {isEmpty} from '../../obj.js'; import { compose as composeTransform, create as createTransform, } from '../../transform.js'; import Executor from './Executor.js'; /** * @const * @type {Array} */ export const ALL = [ 'Polygon', 'Circle', 'LineString', 'Image', 'Text', 'Default', ]; /** * @const * @type {Array} */ export const DECLUTTER = ['Image', 'Text']; /** * @const * @type {Array} */ export const NON_DECLUTTER = ALL.filter( (builderType) => !DECLUTTER.includes(builderType), ); class ExecutorGroup { /** * @param {import("../../extent.js").Extent} maxExtent Max extent for clipping. When a * `maxExtent` was set on the Builder for this executor group, the same `maxExtent` * should be set here, unless the target context does not exceed that extent (which * can be the case when rendering to tiles). * @param {number} resolution Resolution. * @param {number} pixelRatio Pixel ratio. * @param {boolean} overlaps The executor group can have overlapping geometries. * @param {!Object>} allInstructions * The serializable instructions. * @param {number} [renderBuffer] Optional rendering buffer. * @param {boolean} [deferredRendering] Enable deferred rendering with renderDeferred(). */ constructor( maxExtent, resolution, pixelRatio, overlaps, allInstructions, renderBuffer, deferredRendering, ) { /** * @private * @type {import("../../extent.js").Extent} */ this.maxExtent_ = maxExtent; /** * @private * @type {boolean} */ this.overlaps_ = overlaps; /** * @private * @type {number} */ this.pixelRatio_ = pixelRatio; /** * @private * @type {number} */ this.resolution_ = resolution; /** * @private * @type {number|undefined} */ this.renderBuffer_ = renderBuffer; /** * @private * @type {!Object>} */ this.executorsByZIndex_ = {}; /** * @private * @type {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} */ this.hitDetectionContext_ = null; /** * @private * @type {import("../../transform.js").Transform} */ this.hitDetectionTransform_ = createTransform(); /** * @private * @type {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} */ this.renderedContext_ = null; /** * @private * @type {Object>} */ this.deferredZIndexContexts_ = {}; this.createExecutors_(allInstructions, deferredRendering); } /** * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} context Context. * @param {import("../../transform.js").Transform} transform Transform. */ clip(context, transform) { const flatClipCoords = this.getClipCoords(transform); context.beginPath(); context.moveTo(flatClipCoords[0], flatClipCoords[1]); context.lineTo(flatClipCoords[2], flatClipCoords[3]); context.lineTo(flatClipCoords[4], flatClipCoords[5]); context.lineTo(flatClipCoords[6], flatClipCoords[7]); context.clip(); } /** * Create executors and populate them using the provided instructions. * @private * @param {!Object>} allInstructions The serializable instructions * @param {boolean} deferredRendering Enable deferred rendering. */ createExecutors_(allInstructions, deferredRendering) { for (const zIndex in allInstructions) { let executors = this.executorsByZIndex_[zIndex]; if (executors === undefined) { executors = {}; this.executorsByZIndex_[zIndex] = executors; } const instructionByZindex = allInstructions[zIndex]; for (const builderType in instructionByZindex) { const instructions = instructionByZindex[builderType]; executors[builderType] = new Executor( this.resolution_, this.pixelRatio_, this.overlaps_, instructions, deferredRendering, ); } } } /** * @param {Array} executors Executors. * @return {boolean} Has executors of the provided types. */ hasExecutors(executors) { for (const zIndex in this.executorsByZIndex_) { const candidates = this.executorsByZIndex_[zIndex]; for (let i = 0, ii = executors.length; i < ii; ++i) { if (executors[i] in candidates) { return true; } } } return false; } /** * @param {import("../../coordinate.js").Coordinate} coordinate Coordinate. * @param {number} resolution Resolution. * @param {number} rotation Rotation. * @param {number} hitTolerance Hit tolerance in pixels. * @param {function(import("../../Feature.js").FeatureLike, import("../../geom/SimpleGeometry.js").default, number): T} callback Feature callback. * @param {Array} declutteredFeatures Decluttered features. * @return {T|undefined} Callback result. * @template T */ forEachFeatureAtCoordinate( coordinate, resolution, rotation, hitTolerance, callback, declutteredFeatures, ) { hitTolerance = Math.round(hitTolerance); const contextSize = hitTolerance * 2 + 1; const transform = composeTransform( this.hitDetectionTransform_, hitTolerance + 0.5, hitTolerance + 0.5, 1 / resolution, -1 / resolution, -rotation, -coordinate[0], -coordinate[1], ); const newContext = !this.hitDetectionContext_; if (newContext) { // Refrain from adding a 'willReadFrequently' hint in the options here. // While it will remove the "Canvas2D: Multiple readback operations using // getImageData are faster with the willReadFrequently attribute set // to true" warnings in the console, it makes hitDetection extremely // slow in Chrome when there are many features on the map this.hitDetectionContext_ = createCanvasContext2D( contextSize, contextSize, ); } const context = this.hitDetectionContext_; if ( context.canvas.width !== contextSize || context.canvas.height !== contextSize ) { context.canvas.width = contextSize; context.canvas.height = contextSize; } else if (!newContext) { context.clearRect(0, 0, contextSize, contextSize); } /** @type {import("../../extent.js").Extent|undefined} */ let hitExtent; if (this.renderBuffer_ !== undefined) { hitExtent = createEmpty(); extendCoordinate(hitExtent, coordinate); buffer( hitExtent, resolution * (this.renderBuffer_ + hitTolerance), hitExtent, ); } const indexes = getPixelIndexArray(hitTolerance); /** @type {import("../canvas.js").BuilderType} */ let builderType; /** * @param {import("../../Feature.js").FeatureLike} feature Feature. * @param {import("../../geom/SimpleGeometry.js").default} geometry Geometry. * @param {import('../../style/Style.js').DeclutterMode} declutterMode Declutter mode. * @return {T|undefined} Callback result. */ function featureCallback(feature, geometry, declutterMode) { const imageData = context.getImageData( 0, 0, contextSize, contextSize, ).data; for (let i = 0, ii = indexes.length; i < ii; i++) { if (imageData[indexes[i]] > 0) { if ( !declutteredFeatures || declutterMode === 'none' || (builderType !== 'Image' && builderType !== 'Text') || declutteredFeatures.includes(feature) ) { const idx = (indexes[i] - 3) / 4; const x = hitTolerance - (idx % contextSize); const y = hitTolerance - ((idx / contextSize) | 0); const result = callback(feature, geometry, x * x + y * y); if (result) { return result; } } context.clearRect(0, 0, contextSize, contextSize); break; } } return undefined; } /** @type {Array} */ const zs = Object.keys(this.executorsByZIndex_).map(Number); zs.sort(ascending); let i, j, executors, executor, result; for (i = zs.length - 1; i >= 0; --i) { const zIndexKey = zs[i].toString(); executors = this.executorsByZIndex_[zIndexKey]; for (j = ALL.length - 1; j >= 0; --j) { builderType = ALL[j]; executor = executors[builderType]; if (executor !== undefined) { result = executor.executeHitDetection( context, transform, rotation, featureCallback, hitExtent, ); if (result) { return result; } } } } return undefined; } /** * @param {import("../../transform.js").Transform} transform Transform. * @return {Array|null} Clip coordinates. */ getClipCoords(transform) { const maxExtent = this.maxExtent_; if (!maxExtent) { return null; } const minX = maxExtent[0]; const minY = maxExtent[1]; const maxX = maxExtent[2]; const maxY = maxExtent[3]; const flatClipCoords = [minX, minY, minX, maxY, maxX, maxY, maxX, minY]; transform2D(flatClipCoords, 0, 8, 2, transform, flatClipCoords); return flatClipCoords; } /** * @return {boolean} Is empty. */ isEmpty() { return isEmpty(this.executorsByZIndex_); } /** * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} targetContext Context. * @param {import('../../size.js').Size} scaledCanvasSize Scale of the context. * @param {import("../../transform.js").Transform} transform Transform. * @param {number} viewRotation View rotation. * @param {boolean} snapToPixel Snap point symbols and test to integer pixel. * @param {Array} [builderTypes] Ordered replay types to replay. * Default is {@link module:ol/render/replay~ALL} * @param {import("rbush").default|null} [declutterTree] Declutter tree. * When set to null, no decluttering is done, even when the executor group has a `ZIndexContext`. */ execute( targetContext, scaledCanvasSize, transform, viewRotation, snapToPixel, builderTypes, declutterTree, ) { const zs = Object.keys(this.executorsByZIndex_).map(Number); zs.sort(declutterTree ? descending : ascending); builderTypes = builderTypes ? builderTypes : ALL; const maxBuilderTypes = ALL.length; for (let i = 0, ii = zs.length; i < ii; ++i) { const zIndexKey = zs[i].toString(); const replays = this.executorsByZIndex_[zIndexKey]; for (let j = 0, jj = builderTypes.length; j < jj; ++j) { const builderType = builderTypes[j]; const replay = replays[builderType]; if (replay !== undefined) { const zIndexContext = declutterTree === null ? undefined : replay.getZIndexContext(); const context = zIndexContext ? zIndexContext.getContext() : targetContext; const requireClip = this.maxExtent_ && builderType !== 'Image' && builderType !== 'Text'; if (requireClip) { context.save(); // setup clipping so that the parts of over-simplified geometries are not // visible outside the current extent when panning this.clip(context, transform); } if ( !zIndexContext || builderType === 'Text' || builderType === 'Image' ) { replay.execute( context, scaledCanvasSize, transform, viewRotation, snapToPixel, declutterTree, ); } else { zIndexContext.pushFunction((context) => replay.execute( context, scaledCanvasSize, transform, viewRotation, snapToPixel, declutterTree, ), ); } if (requireClip) { context.restore(); } if (zIndexContext) { zIndexContext.offset(); const index = zs[i] * maxBuilderTypes + ALL.indexOf(builderType); if (!this.deferredZIndexContexts_[index]) { this.deferredZIndexContexts_[index] = []; } this.deferredZIndexContexts_[index].push(zIndexContext); } } } } this.renderedContext_ = targetContext; } getDeferredZIndexContexts() { return this.deferredZIndexContexts_; } getRenderedContext() { return this.renderedContext_; } renderDeferred() { const deferredZIndexContexts = this.deferredZIndexContexts_; const zs = Object.keys(deferredZIndexContexts).map(Number).sort(ascending); for (let i = 0, ii = zs.length; i < ii; ++i) { deferredZIndexContexts[zs[i]].forEach((zIndexContext) => { zIndexContext.draw(this.renderedContext_); // FIXME Pass clip to replay for temporarily enabling clip zIndexContext.clear(); }); deferredZIndexContexts[zs[i]].length = 0; } } } /** * This cache is used to store arrays of indexes for calculated pixel circles * to increase performance. * It is a static property to allow each Replaygroup to access it. * @type {Object>} */ const circlePixelIndexArrayCache = {}; /** * This methods creates an array with indexes of all pixels within a circle, * ordered by how close they are to the center. * A cache is used to increase performance. * @param {number} radius Radius. * @return {Array} An array with indexes within a circle. */ export function getPixelIndexArray(radius) { if (circlePixelIndexArrayCache[radius] !== undefined) { return circlePixelIndexArrayCache[radius]; } const size = radius * 2 + 1; const maxDistanceSq = radius * radius; const distances = new Array(maxDistanceSq + 1); for (let i = 0; i <= radius; ++i) { for (let j = 0; j <= radius; ++j) { const distanceSq = i * i + j * j; if (distanceSq > maxDistanceSq) { break; } let distance = distances[distanceSq]; if (!distance) { distance = []; distances[distanceSq] = distance; } distance.push(((radius + i) * size + (radius + j)) * 4 + 3); if (i > 0) { distance.push(((radius - i) * size + (radius + j)) * 4 + 3); } if (j > 0) { distance.push(((radius + i) * size + (radius - j)) * 4 + 3); if (i > 0) { distance.push(((radius - i) * size + (radius - j)) * 4 + 3); } } } } const pixelIndex = []; for (let i = 0, ii = distances.length; i < ii; ++i) { if (distances[i]) { pixelIndex.push(...distances[i]); } } circlePixelIndexArrayCache[radius] = pixelIndex; return pixelIndex; } export default ExecutorGroup;