ol
Version:
OpenLayers mapping library
854 lines (788 loc) • 24.8 kB
JavaScript
/**
* @module ol/renderer/canvas/VectorLayer
*/
import ViewHint from '../../ViewHint.js';
import {equals} from '../../array.js';
import {wrapX as wrapCoordinateX} from '../../coordinate.js';
import {createCanvasContext2D, releaseCanvas} from '../../dom.js';
import {
buffer,
containsExtent,
createEmpty,
getHeight,
getWidth,
intersects as intersectsExtent,
isEmpty,
wrapX as wrapExtentX,
} from '../../extent.js';
import {
fromUserExtent,
getTransformFromProjections,
getUserProjection,
toUserExtent,
toUserResolution,
} from '../../proj.js';
import RenderEventType from '../../render/EventType.js';
import CanvasBuilderGroup from '../../render/canvas/BuilderGroup.js';
import ExecutorGroup, {
ALL,
DECLUTTER,
NON_DECLUTTER,
} from '../../render/canvas/ExecutorGroup.js';
import {
HIT_DETECT_RESOLUTION,
createHitDetectionImageData,
hitDetect,
} from '../../render/canvas/hitdetect.js';
import {getUid} from '../../util.js';
import {
defaultOrder as defaultRenderOrder,
getSquaredTolerance as getSquaredRenderTolerance,
getTolerance as getRenderTolerance,
renderFeature,
} from '../vector.js';
import CanvasLayerRenderer, {canvasPool} from './Layer.js';
/**
* @classdesc
* Canvas renderer for vector layers.
* @api
*/
class CanvasVectorLayerRenderer extends CanvasLayerRenderer {
/**
* @param {import("../../layer/BaseVector.js").default} vectorLayer Vector layer.
*/
constructor(vectorLayer) {
super(vectorLayer);
/** @private */
this.boundHandleStyleImageChange_ = this.handleStyleImageChange_.bind(this);
/**
* @private
* @type {boolean}
*/
this.animatingOrInteracting_;
/**
* @private
* @type {ImageData|null}
*/
this.hitDetectionImageData_ = null;
/**
* @private
* @type {import("../../extent.js").Extent}
*/
this.clipExtent_ = null;
/**
* Do we need to extend the rendered area on the x-axis to handle
* features that cross the antimeridian?
* @private
* @type {boolean}
*/
this.extendX_ = false;
/**
* @private
* @type {Array<import("../../Feature.js").default>}
*/
this.renderedFeatures_ = null;
/**
* @private
* @type {number}
*/
this.renderedRevision_ = -1;
/**
* @private
* @type {number}
*/
this.renderedResolution_ = NaN;
/**
* @private
* @type {import("../../extent.js").Extent}
*/
this.renderedExtent_ = createEmpty();
/**
* @private
* @type {import("../../extent.js").Extent}
*/
this.wrappedRenderedExtent_ = createEmpty();
/**
* @private
* @type {number}
*/
this.renderedRotation_;
/**
* @private
* @type {import("../../coordinate").Coordinate}
*/
this.renderedCenter_ = null;
/**
* @private
* @type {import("../../proj/Projection").default}
*/
this.renderedProjection_ = null;
/**
* @private
* @type {number}
*/
this.renderedPixelRatio_ = 1;
/**
* @private
* @type {import("../../render.js").OrderFunction|null}
*/
this.renderedRenderOrder_ = null;
/**
* @private
* @type {boolean}
*/
this.renderedFrameDeclutter_;
/**
* @private
* @type {import("../../render/canvas/ExecutorGroup").default}
*/
this.replayGroup_ = null;
/**
* A new replay group had to be created by `prepareFrame()`
* @type {boolean}
*/
this.replayGroupChanged = true;
/**
* Clipping to be performed by `renderFrame()`
* @type {boolean}
*/
this.clipping = true;
/**
* @private
* @type {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D}
*/
this.targetContext_ = null;
/**
* @private
* @type {number}
*/
this.opacity_ = 1;
}
/**
* @param {ExecutorGroup} executorGroup Executor group.
* @param {import("../../Map.js").FrameState} frameState Frame state.
* @param {boolean} [declutterable] `true` to only render declutterable items,
* `false` to only render non-declutterable items, `undefined` to render all.
*/
renderWorlds(executorGroup, frameState, declutterable) {
const extent = frameState.extent;
const viewState = frameState.viewState;
const center = viewState.center;
const resolution = viewState.resolution;
const projection = viewState.projection;
const rotation = viewState.rotation;
const projectionExtent = projection.getExtent();
const vectorSource = this.getLayer().getSource();
const declutter = this.getLayer().getDeclutter();
const pixelRatio = frameState.pixelRatio;
const viewHints = frameState.viewHints;
const snapToPixel = !(
viewHints[ViewHint.ANIMATING] || viewHints[ViewHint.INTERACTING]
);
const context = this.context;
const width = Math.round((getWidth(extent) / resolution) * pixelRatio);
const height = Math.round((getHeight(extent) / resolution) * pixelRatio);
const multiWorld = vectorSource.getWrapX() && projection.canWrapX();
const worldWidth = multiWorld ? getWidth(projectionExtent) : null;
const endWorld = multiWorld
? Math.ceil((extent[2] - projectionExtent[2]) / worldWidth) +
(this.extendX_ ? 2 : 1)
: 1;
let world = multiWorld
? Math.floor((extent[0] - projectionExtent[0]) / worldWidth) -
(this.extendX_ ? 1 : 0)
: 0;
do {
let transform = this.getRenderTransform(
center,
resolution,
0,
pixelRatio,
width,
height,
world * worldWidth,
);
if (frameState.declutter) {
transform = transform.slice(0);
}
executorGroup.execute(
context,
[context.canvas.width, context.canvas.height],
transform,
rotation,
snapToPixel,
declutterable === undefined
? ALL
: declutterable
? DECLUTTER
: NON_DECLUTTER,
declutterable
? declutter && frameState.declutter[declutter]
: undefined,
);
} while (++world < endWorld);
}
/**
* @private
*/
setDrawContext_() {
if (this.opacity_ !== 1) {
this.targetContext_ = this.context;
this.context = createCanvasContext2D(
this.context.canvas.width,
this.context.canvas.height,
canvasPool,
);
}
}
/**
* @private
*/
resetDrawContext_() {
if (this.opacity_ !== 1 && this.targetContext_) {
const alpha = this.targetContext_.globalAlpha;
this.targetContext_.globalAlpha = this.opacity_;
this.targetContext_.drawImage(this.context.canvas, 0, 0);
this.targetContext_.globalAlpha = alpha;
releaseCanvas(this.context);
canvasPool.push(this.context.canvas);
this.context = this.targetContext_;
this.targetContext_ = null;
}
}
/**
* Render declutter items for this layer
* @param {import("../../Map.js").FrameState} frameState Frame state.
*/
renderDeclutter(frameState) {
if (!this.replayGroup_ || !this.getLayer().getDeclutter()) {
return;
}
this.renderWorlds(this.replayGroup_, frameState, true);
}
/**
* Render deferred instructions.
* @param {import("../../Map.js").FrameState} frameState Frame state.
* @override
*/
renderDeferredInternal(frameState) {
if (!this.replayGroup_) {
return;
}
if (this.clipExtent_) {
this.clipUnrotated(this.context, frameState, this.clipExtent_);
}
this.replayGroup_.renderDeferred();
if (this.clipExtent_) {
this.context.restore();
this.clipExtent_ = null;
}
this.resetDrawContext_();
}
/**
* Render the layer.
* @param {import("../../Map.js").FrameState} frameState Frame state.
* @param {HTMLElement|null} target Target that may be used to render content to.
* @return {HTMLElement} The rendered element.
* @override
*/
renderFrame(frameState, target) {
const layerState = frameState.layerStatesArray[frameState.layerIndex];
this.opacity_ = layerState.opacity;
const viewState = frameState.viewState;
this.prepareContainer(frameState, target);
const context = this.context;
const replayGroup = this.replayGroup_;
let render = replayGroup && !replayGroup.isEmpty();
if (!render) {
const hasRenderListeners =
this.getLayer().hasListener(RenderEventType.PRERENDER) ||
this.getLayer().hasListener(RenderEventType.POSTRENDER);
if (!hasRenderListeners) {
return this.container;
}
}
this.setDrawContext_();
this.preRender(context, frameState);
const projection = viewState.projection;
// clipped rendering if layer extent is set
this.clipExtent_ = null;
let clipped = false;
if (render && layerState.extent && this.clipping) {
const layerExtent = fromUserExtent(layerState.extent, projection);
render = intersectsExtent(layerExtent, frameState.extent);
const needsClip =
render && !containsExtent(layerExtent, frameState.extent);
if (needsClip) {
if (frameState.declutter) {
// Store extent for deferred clipping
this.clipExtent_ = layerExtent;
} else {
// Apply clipping immediately for non-declutter rendering
this.clipUnrotated(context, frameState, layerExtent);
clipped = true;
}
}
}
if (render) {
this.renderWorlds(
replayGroup,
frameState,
this.getLayer().getDeclutter() ? false : undefined,
);
}
if (clipped) {
context.restore();
}
this.postRender(context, frameState);
if (this.renderedRotation_ !== viewState.rotation) {
this.renderedRotation_ = viewState.rotation;
this.hitDetectionImageData_ = null;
}
if (!frameState.declutter) {
this.resetDrawContext_();
}
return this.container;
}
/**
* Asynchronous layer level hit detection.
* @param {import("../../pixel.js").Pixel} pixel Pixel.
* @return {Promise<Array<import("../../Feature").default>>} Promise
* that resolves with an array of features.
* @override
*/
getFeatures(pixel) {
return new Promise((resolve) => {
if (
this.frameState &&
!this.hitDetectionImageData_ &&
!this.animatingOrInteracting_
) {
const size = this.frameState.size.slice();
const center = this.renderedCenter_;
const resolution = this.renderedResolution_;
const rotation = this.renderedRotation_;
const projection = this.renderedProjection_;
const extent = this.wrappedRenderedExtent_;
const layer = this.getLayer();
const transforms = [];
const width = size[0] * HIT_DETECT_RESOLUTION;
const height = size[1] * HIT_DETECT_RESOLUTION;
transforms.push(
this.getRenderTransform(
center,
resolution,
rotation,
HIT_DETECT_RESOLUTION,
width,
height,
0,
).slice(),
);
const source = layer.getSource();
const projectionExtent = projection.getExtent();
if (
source.getWrapX() &&
projection.canWrapX() &&
!containsExtent(projectionExtent, extent)
) {
let startX = extent[0];
const worldWidth = getWidth(projectionExtent);
let world = 0;
let offsetX;
while (startX < projectionExtent[0]) {
--world;
offsetX = worldWidth * world;
transforms.push(
this.getRenderTransform(
center,
resolution,
rotation,
HIT_DETECT_RESOLUTION,
width,
height,
offsetX,
).slice(),
);
startX += worldWidth;
}
world = 0;
startX = extent[2];
while (startX > projectionExtent[2]) {
++world;
offsetX = worldWidth * world;
transforms.push(
this.getRenderTransform(
center,
resolution,
rotation,
HIT_DETECT_RESOLUTION,
width,
height,
offsetX,
).slice(),
);
startX -= worldWidth;
}
}
const userProjection = getUserProjection();
this.hitDetectionImageData_ = createHitDetectionImageData(
size,
transforms,
this.renderedFeatures_,
layer.getStyleFunction(),
extent,
resolution,
rotation,
getSquaredRenderTolerance(resolution, this.renderedPixelRatio_),
userProjection ? projection : null,
);
}
resolve(
hitDetect(pixel, this.renderedFeatures_, this.hitDetectionImageData_),
);
});
}
/**
* @param {import("../../coordinate.js").Coordinate} coordinate Coordinate.
* @param {import("../../Map.js").FrameState} frameState Frame state.
* @param {number} hitTolerance Hit tolerance in pixels.
* @param {import("../vector.js").FeatureCallback<T>} callback Feature callback.
* @param {Array<import("../Map.js").HitMatch<T>>} matches The hit detected matches with tolerance.
* @return {T|undefined} Callback result.
* @template T
* @override
*/
forEachFeatureAtCoordinate(
coordinate,
frameState,
hitTolerance,
callback,
matches,
) {
if (!this.replayGroup_) {
return undefined;
}
const resolution = frameState.viewState.resolution;
const rotation = frameState.viewState.rotation;
const layer = this.getLayer();
/** @type {!Object<string, import("../Map.js").HitMatch<T>|true>} */
const features = {};
/**
* @param {import("../../Feature.js").FeatureLike} feature Feature.
* @param {import("../../geom/SimpleGeometry.js").default} geometry Geometry.
* @param {number} distanceSq The squared distance to the click position
* @return {T|undefined} Callback result.
*/
const featureCallback = function (feature, geometry, distanceSq) {
const key = getUid(feature);
const match = features[key];
if (!match) {
if (distanceSq === 0) {
features[key] = true;
return callback(feature, layer, geometry);
}
matches.push(
(features[key] = {
feature: feature,
layer: layer,
geometry: geometry,
distanceSq: distanceSq,
callback: callback,
}),
);
} else if (match !== true && distanceSq < match.distanceSq) {
if (distanceSq === 0) {
features[key] = true;
matches.splice(matches.lastIndexOf(match), 1);
return callback(feature, layer, geometry);
}
match.geometry = geometry;
match.distanceSq = distanceSq;
}
return undefined;
};
const declutter = this.getLayer().getDeclutter();
return this.replayGroup_.forEachFeatureAtCoordinate(
coordinate,
resolution,
rotation,
hitTolerance,
featureCallback,
declutter
? frameState.declutter?.[declutter]?.all().map((item) => item.value)
: null,
);
}
/**
* Perform action necessary to get the layer rendered after new fonts have loaded
* @override
*/
handleFontsChanged() {
const layer = this.getLayer();
if (layer.getVisible() && this.replayGroup_) {
layer.changed();
}
}
/**
* Handle changes in image style state.
* @param {import("../../events/Event.js").default} event Image style change event.
* @private
*/
handleStyleImageChange_(event) {
this.renderIfReadyAndVisible();
}
/**
* Determine whether render should be called.
* @param {import("../../Map.js").FrameState} frameState Frame state.
* @return {boolean} Layer is ready to be rendered.
* @override
*/
prepareFrame(frameState) {
const vectorLayer = this.getLayer();
const vectorSource = vectorLayer.getSource();
if (!vectorSource) {
return false;
}
const animating = frameState.viewHints[ViewHint.ANIMATING];
const interacting = frameState.viewHints[ViewHint.INTERACTING];
const updateWhileAnimating = vectorLayer.getUpdateWhileAnimating();
const updateWhileInteracting = vectorLayer.getUpdateWhileInteracting();
if (
(this.ready && !updateWhileAnimating && animating) ||
(!updateWhileInteracting && interacting)
) {
this.animatingOrInteracting_ = true;
return true;
}
this.animatingOrInteracting_ = false;
const frameStateExtent = frameState.extent;
const viewState = frameState.viewState;
const projection = viewState.projection;
const resolution = viewState.resolution;
const pixelRatio = frameState.pixelRatio;
const vectorLayerRevision = vectorLayer.getRevision();
const vectorLayerRenderBuffer = vectorLayer.getRenderBuffer();
let vectorLayerRenderOrder = vectorLayer.getRenderOrder();
if (vectorLayerRenderOrder === undefined) {
vectorLayerRenderOrder = defaultRenderOrder;
}
const center = viewState.center.slice();
const extent = buffer(
frameStateExtent,
vectorLayerRenderBuffer * resolution,
);
const renderedExtent = extent.slice();
const loadExtents = [extent.slice()];
const projectionExtent = projection.getExtent();
const canWrapX = vectorSource.getWrapX() && projection.canWrapX();
this.extendX_ = false;
if (canWrapX) {
const sourceExtent = vectorSource.getExtent();
if (sourceExtent && !isEmpty(sourceExtent)) {
this.extendX_ =
sourceExtent[0] < projectionExtent[0] ||
sourceExtent[2] > projectionExtent[2];
}
}
if (
canWrapX &&
(!containsExtent(projectionExtent, frameState.extent) || this.extendX_)
) {
// For the replay group, we need an extent that intersects the real world
// (-180° to +180°). To support geometries in a coordinate range from -540°
// to +540°, we add at least 1 world width on each side of the projection
// extent. If the viewport is wider than the world, we need to add half of
// the viewport width to make sure we cover the whole viewport.
const worldWidth = getWidth(projectionExtent);
const gutter = Math.max(getWidth(extent) / 2, worldWidth);
let projMinX = projectionExtent[0];
let projMaxX = projectionExtent[2];
if (this.extendX_) {
projMinX -= worldWidth;
projMaxX += worldWidth;
}
extent[0] = projMinX - gutter;
extent[2] = projMaxX + gutter;
wrapCoordinateX(center, projection);
const loadExtent = wrapExtentX(loadExtents[0], projection);
// If the extent crosses the date line, we load data for both edges of the worlds
if (
loadExtent[0] < projectionExtent[0] &&
loadExtent[2] < projectionExtent[2]
) {
loadExtents.push([
loadExtent[0] + worldWidth,
loadExtent[1],
loadExtent[2] + worldWidth,
loadExtent[3],
]);
} else if (
loadExtent[0] > projectionExtent[0] &&
loadExtent[2] > projectionExtent[2]
) {
loadExtents.push([
loadExtent[0] - worldWidth,
loadExtent[1],
loadExtent[2] - worldWidth,
loadExtent[3],
]);
}
}
if (
this.ready &&
this.renderedResolution_ == resolution &&
this.renderedPixelRatio_ === pixelRatio &&
this.renderedRevision_ == vectorLayerRevision &&
this.renderedRenderOrder_ == vectorLayerRenderOrder &&
this.renderedFrameDeclutter_ === !!frameState.declutter &&
containsExtent(this.wrappedRenderedExtent_, extent)
) {
if (!equals(this.renderedExtent_, renderedExtent)) {
this.hitDetectionImageData_ = null;
this.renderedExtent_ = renderedExtent;
}
this.renderedCenter_ = center;
this.replayGroupChanged = false;
return true;
}
this.replayGroup_ = null;
const replayGroup = new CanvasBuilderGroup(
getRenderTolerance(resolution, pixelRatio),
extent,
resolution,
pixelRatio,
);
const userProjection = getUserProjection();
let userTransform;
if (userProjection) {
for (let i = 0, ii = loadExtents.length; i < ii; ++i) {
const extent = loadExtents[i];
const userExtent = toUserExtent(extent, projection);
vectorSource.loadFeatures(
userExtent,
toUserResolution(resolution, projection),
userProjection,
);
}
userTransform = getTransformFromProjections(userProjection, projection);
} else {
for (let i = 0, ii = loadExtents.length; i < ii; ++i) {
vectorSource.loadFeatures(loadExtents[i], resolution, projection);
}
}
const squaredTolerance = getSquaredRenderTolerance(resolution, pixelRatio);
let ready = true;
const render =
/**
* @param {import("../../Feature.js").default} feature Feature.
* @param {number} index Index.
*/
(feature, index) => {
let styles;
const styleFunction =
feature.getStyleFunction() || vectorLayer.getStyleFunction();
if (styleFunction) {
styles = styleFunction(feature, resolution);
}
if (styles) {
const dirty = this.renderFeature(
feature,
squaredTolerance,
styles,
replayGroup,
userTransform,
this.getLayer().getDeclutter(),
index,
);
ready = ready && !dirty;
}
};
const userExtent = toUserExtent(extent, projection);
/** @type {Array<import("../../Feature.js").default>} */
const features = vectorSource.getFeaturesInExtent(userExtent);
if (vectorLayerRenderOrder) {
features.sort(vectorLayerRenderOrder);
}
for (let i = 0, ii = features.length; i < ii; ++i) {
render(features[i], i);
}
this.renderedFeatures_ = features;
this.ready = ready;
const replayGroupInstructions = replayGroup.finish();
const executorGroup = new ExecutorGroup(
extent,
resolution,
pixelRatio,
vectorSource.getOverlaps(),
replayGroupInstructions,
vectorLayer.getRenderBuffer(),
!!frameState.declutter,
);
this.renderedResolution_ = resolution;
this.renderedRevision_ = vectorLayerRevision;
this.renderedRenderOrder_ = vectorLayerRenderOrder;
this.renderedFrameDeclutter_ = !!frameState.declutter;
this.renderedExtent_ = renderedExtent;
this.wrappedRenderedExtent_ = extent;
this.renderedCenter_ = center;
this.renderedProjection_ = projection;
this.renderedPixelRatio_ = pixelRatio;
this.replayGroup_ = executorGroup;
this.hitDetectionImageData_ = null;
this.replayGroupChanged = true;
return true;
}
/**
* @param {import("../../Feature.js").default} feature Feature.
* @param {number} squaredTolerance Squared render tolerance.
* @param {import("../../style/Style.js").default|Array<import("../../style/Style.js").default>} styles The style or array of styles.
* @param {import("../../render/canvas/BuilderGroup.js").default} builderGroup Builder group.
* @param {import("../../proj.js").TransformFunction} [transform] Transform from user to view projection.
* @param {boolean} [declutter] Enable decluttering.
* @param {number} [index] Render order index.
* @return {boolean} `true` if an image is loading.
*/
renderFeature(
feature,
squaredTolerance,
styles,
builderGroup,
transform,
declutter,
index,
) {
if (!styles) {
return false;
}
let loading = false;
if (Array.isArray(styles)) {
for (let i = 0, ii = styles.length; i < ii; ++i) {
loading =
renderFeature(
builderGroup,
feature,
styles[i],
squaredTolerance,
this.boundHandleStyleImageChange_,
transform,
declutter,
index,
) || loading;
}
} else {
loading = renderFeature(
builderGroup,
feature,
styles,
squaredTolerance,
this.boundHandleStyleImageChange_,
transform,
declutter,
index,
);
}
return loading;
}
}
export default CanvasVectorLayerRenderer;