diff --git a/src/ol/render/canvas/Executor.js b/src/ol/render/canvas/Executor.js index b424dfb7bd..c406ebb905 100644 --- a/src/ol/render/canvas/Executor.js +++ b/src/ol/render/canvas/Executor.js @@ -808,17 +808,21 @@ class Executor { instruction[12] ); let width = /** @type {number} */ (instruction[13]); - const declutterImageWithText = - /** @type {import("../canvas.js").DeclutterImageWithText} */ ( + const declutterMode = + /** @type {"declutter"|"obstacle"|"none"|undefined} */ ( instruction[14] ); + const declutterImageWithText = + /** @type {import("../canvas.js").DeclutterImageWithText} */ ( + instruction[15] + ); - if (!image && instruction.length >= 19) { + if (!image && instruction.length >= 20) { // create label images - text = /** @type {string} */ (instruction[18]); - textKey = /** @type {string} */ (instruction[19]); - strokeKey = /** @type {string} */ (instruction[20]); - fillKey = /** @type {string} */ (instruction[21]); + text = /** @type {string} */ (instruction[19]); + textKey = /** @type {string} */ (instruction[20]); + strokeKey = /** @type {string} */ (instruction[21]); + fillKey = /** @type {string} */ (instruction[22]); const labelWithAnchor = this.drawLabelWithPointPlacement_( text, textKey, @@ -827,10 +831,10 @@ class Executor { ); image = labelWithAnchor.label; instruction[3] = image; - const textOffsetX = /** @type {number} */ (instruction[22]); + const textOffsetX = /** @type {number} */ (instruction[23]); anchorX = (labelWithAnchor.anchorX - textOffsetX) * this.pixelRatio; instruction[4] = anchorX; - const textOffsetY = /** @type {number} */ (instruction[23]); + const textOffsetY = /** @type {number} */ (instruction[24]); anchorY = (labelWithAnchor.anchorY - textOffsetY) * this.pixelRatio; instruction[5] = anchorY; height = image.height; @@ -840,15 +844,15 @@ class Executor { } let geometryWidths; - if (instruction.length > 24) { - geometryWidths = /** @type {number} */ (instruction[24]); + if (instruction.length > 25) { + geometryWidths = /** @type {number} */ (instruction[25]); } let padding, backgroundFill, backgroundStroke; - if (instruction.length > 16) { - padding = /** @type {Array} */ (instruction[15]); - backgroundFill = /** @type {boolean} */ (instruction[16]); - backgroundStroke = /** @type {boolean} */ (instruction[17]); + if (instruction.length > 17) { + padding = /** @type {Array} */ (instruction[16]); + backgroundFill = /** @type {boolean} */ (instruction[17]); + backgroundStroke = /** @type {boolean} */ (instruction[18]); } else { padding = defaultPadding; backgroundFill = false; @@ -902,39 +906,43 @@ class Executor { ? /** @type {Array<*>} */ (lastStrokeInstruction) : null, ]; - let imageArgs; - let imageDeclutterBox; - if (opt_declutterTree && declutterImageWithText) { - const index = dd - d; - if (!declutterImageWithText[index]) { - // We now have the image for an image+text combination. - declutterImageWithText[index] = args; - // Don't render anything for now, wait for the text. - continue; - } - imageArgs = declutterImageWithText[index]; - delete declutterImageWithText[index]; - imageDeclutterBox = getDeclutterBox(imageArgs); - if (opt_declutterTree.collides(imageDeclutterBox)) { - continue; - } - } - if ( - opt_declutterTree && - opt_declutterTree.collides(dimensions.declutterBox) - ) { - continue; - } - if (imageArgs) { - // We now have image and text for an image+text combination. - if (opt_declutterTree) { - opt_declutterTree.insert(imageDeclutterBox); - } - // Render the image before we render the text. - this.replayImageOrLabel_.apply(this, imageArgs); - } if (opt_declutterTree) { - opt_declutterTree.insert(dimensions.declutterBox); + if (declutterMode === 'none') { + // not rendered in declutter group + continue; + } else if (declutterMode === 'obstacle') { + // will always be drawn, thus no collision detection, but insert as obstacle + opt_declutterTree.insert(dimensions.declutterBox); + continue; + } else { + let imageArgs; + let imageDeclutterBox; + if (declutterImageWithText) { + const index = dd - d; + if (!declutterImageWithText[index]) { + // We now have the image for an image+text combination. + declutterImageWithText[index] = args; + // Don't render anything for now, wait for the text. + continue; + } + imageArgs = declutterImageWithText[index]; + delete declutterImageWithText[index]; + imageDeclutterBox = getDeclutterBox(imageArgs); + if (opt_declutterTree.collides(imageDeclutterBox)) { + continue; + } + } + if (opt_declutterTree.collides(dimensions.declutterBox)) { + continue; + } + if (imageArgs) { + // We now have image and text for an image+text combination. + opt_declutterTree.insert(imageDeclutterBox); + // Render the image before we render the text. + this.replayImageOrLabel_.apply(this, imageArgs); + } + opt_declutterTree.insert(dimensions.declutterBox); + } } this.replayImageOrLabel_.apply(this, args); } diff --git a/src/ol/render/canvas/ImageBuilder.js b/src/ol/render/canvas/ImageBuilder.js index 8407fc560b..0768333f0d 100644 --- a/src/ol/render/canvas/ImageBuilder.js +++ b/src/ol/render/canvas/ImageBuilder.js @@ -92,6 +92,12 @@ class CanvasImageBuilder extends CanvasBuilder { */ this.width_ = undefined; + /** + * @private + * @type {"declutter"|"obstacle"|"none"|undefined} + */ + this.declutterMode_ = undefined; + /** * Data shared with a text builder for combined decluttering. * @private @@ -132,6 +138,7 @@ class CanvasImageBuilder extends CanvasBuilder { (this.scale_[1] * this.pixelRatio) / this.imagePixelRatio_, ], Math.ceil(this.width_ * this.imagePixelRatio_), + this.declutterMode_, this.declutterImageWithText_, ]); this.hitDetectionInstructions.push([ @@ -150,6 +157,7 @@ class CanvasImageBuilder extends CanvasBuilder { this.rotation_, this.scale_, this.width_, + this.declutterMode_, this.declutterImageWithText_, ]); this.endGeometry(feature); @@ -187,6 +195,7 @@ class CanvasImageBuilder extends CanvasBuilder { (this.scale_[1] * this.pixelRatio) / this.imagePixelRatio_, ], Math.ceil(this.width_ * this.imagePixelRatio_), + this.declutterMode_, this.declutterImageWithText_, ]); this.hitDetectionInstructions.push([ @@ -205,6 +214,7 @@ class CanvasImageBuilder extends CanvasBuilder { this.rotation_, this.scale_, this.width_, + this.declutterMode_, this.declutterImageWithText_, ]); this.endGeometry(feature); @@ -255,6 +265,7 @@ class CanvasImageBuilder extends CanvasBuilder { this.rotation_ = imageStyle.getRotation(); this.scale_ = imageStyle.getScaleArray(); this.width_ = size[0]; + this.declutterMode_ = imageStyle.getDeclutterMode(); this.declutterImageWithText_ = opt_sharedData; } } diff --git a/src/ol/render/canvas/TextBuilder.js b/src/ol/render/canvas/TextBuilder.js index 3e1bb4a45e..b45ccc805d 100644 --- a/src/ol/render/canvas/TextBuilder.js +++ b/src/ol/render/canvas/TextBuilder.js @@ -374,6 +374,7 @@ class CanvasTextBuilder extends CanvasBuilder { this.textRotation_, [1, 1], NaN, + undefined, this.declutterImageWithText_, padding == defaultPadding ? defaultPadding @@ -406,6 +407,7 @@ class CanvasTextBuilder extends CanvasBuilder { this.textRotation_, [scale, scale], NaN, + undefined, this.declutterImageWithText_, padding, !!textState.backgroundFill, diff --git a/src/ol/renderer/vector.js b/src/ol/renderer/vector.js index c59b36296f..86fcc2266c 100644 --- a/src/ol/renderer/vector.js +++ b/src/ol/renderer/vector.js @@ -362,16 +362,29 @@ function renderPointGeometry( const textStyle = style.getText(); /** @type {import("../render/canvas.js").DeclutterImageWithText} */ let declutterImageWithText; - if (opt_declutterBuilderGroup) { - builderGroup = opt_declutterBuilderGroup; - declutterImageWithText = - imageStyle && textStyle && textStyle.getText() ? {} : undefined; - } if (imageStyle) { if (imageStyle.getImageState() != ImageState.LOADED) { return; } - const imageReplay = builderGroup.getBuilder( + let imageBuilderGroup = builderGroup; + if (opt_declutterBuilderGroup) { + const declutterMode = imageStyle.getDeclutterMode(); + if (declutterMode !== 'none') { + imageBuilderGroup = opt_declutterBuilderGroup; + if (declutterMode === 'obstacle') { + // draw in non-declutter group: + const imageReplay = builderGroup.getBuilder( + style.getZIndex(), + BuilderType.IMAGE + ); + imageReplay.setImageStyle(imageStyle, declutterImageWithText); + imageReplay.drawPoint(geometry, feature); + } else if (textStyle && textStyle.getText()) { + declutterImageWithText = {}; + } + } + } + const imageReplay = imageBuilderGroup.getBuilder( style.getZIndex(), BuilderType.IMAGE ); @@ -379,7 +392,11 @@ function renderPointGeometry( imageReplay.drawPoint(geometry, feature); } if (textStyle && textStyle.getText()) { - const textReplay = builderGroup.getBuilder( + let textBuilderGroup = builderGroup; + if (opt_declutterBuilderGroup) { + textBuilderGroup = opt_declutterBuilderGroup; + } + const textReplay = textBuilderGroup.getBuilder( style.getZIndex(), BuilderType.TEXT ); @@ -406,16 +423,29 @@ function renderMultiPointGeometry( const textStyle = style.getText(); /** @type {import("../render/canvas.js").DeclutterImageWithText} */ let declutterImageWithText; - if (opt_declutterBuilderGroup) { - builderGroup = opt_declutterBuilderGroup; - declutterImageWithText = - imageStyle && textStyle && textStyle.getText() ? {} : undefined; - } if (imageStyle) { if (imageStyle.getImageState() != ImageState.LOADED) { return; } - const imageReplay = builderGroup.getBuilder( + let imageBuilderGroup = builderGroup; + if (opt_declutterBuilderGroup) { + const declutterMode = imageStyle.getDeclutterMode(); + if (declutterMode !== 'none') { + imageBuilderGroup = opt_declutterBuilderGroup; + if (declutterMode === 'obstacle') { + // draw in non-declutter group: + const imageReplay = builderGroup.getBuilder( + style.getZIndex(), + BuilderType.IMAGE + ); + imageReplay.setImageStyle(imageStyle, declutterImageWithText); + imageReplay.drawMultiPoint(geometry, feature); + } else if (textStyle && textStyle.getText()) { + declutterImageWithText = {}; + } + } + } + const imageReplay = imageBuilderGroup.getBuilder( style.getZIndex(), BuilderType.IMAGE ); @@ -423,7 +453,11 @@ function renderMultiPointGeometry( imageReplay.drawMultiPoint(geometry, feature); } if (textStyle && textStyle.getText()) { - const textReplay = (opt_declutterBuilderGroup || builderGroup).getBuilder( + let textBuilderGroup = builderGroup; + if (opt_declutterBuilderGroup) { + textBuilderGroup = opt_declutterBuilderGroup; + } + const textReplay = textBuilderGroup.getBuilder( style.getZIndex(), BuilderType.TEXT ); diff --git a/src/ol/style/Circle.js b/src/ol/style/Circle.js index ef890c3a58..81503eb725 100644 --- a/src/ol/style/Circle.js +++ b/src/ol/style/Circle.js @@ -16,6 +16,7 @@ import RegularShape from './RegularShape.js'; * (positive rotation clockwise, meaningful only when used in conjunction with a two dimensional scale). * @property {boolean} [rotateWithView=false] Whether to rotate the shape with the view * (meaningful only when used in conjunction with a two dimensional scale). + * @property {"declutter"|"obstacle"|"none"|undefined} [declutterMode] Declutter mode */ /** @@ -41,6 +42,7 @@ class CircleStyle extends RegularShape { options.rotateWithView !== undefined ? options.rotateWithView : false, displacement: options.displacement !== undefined ? options.displacement : [0, 0], + declutterMode: options.declutterMode, }); } @@ -59,6 +61,7 @@ class CircleStyle extends RegularShape { rotation: this.getRotation(), rotateWithView: this.getRotateWithView(), displacement: this.getDisplacement().slice(), + declutterMode: this.getDeclutterMode(), }); style.setOpacity(this.getOpacity()); return style; diff --git a/src/ol/style/Icon.js b/src/ol/style/Icon.js index 58c52a60f7..03440a9d5f 100644 --- a/src/ol/style/Icon.js +++ b/src/ol/style/Icon.js @@ -44,6 +44,7 @@ import {getUid} from '../util.js'; * @property {import("../size.js").Size} [imgSize] Image size in pixels. Only required if `img` is set and `src` is not, and * for SVG images in Internet Explorer 11. The provided `imgSize` needs to match the actual size of the image. * @property {string} [src] Image source URI. + * @property {"declutter"|"obstacle"|"none"|undefined} [declutterMode] Declutter mode */ /** @@ -86,6 +87,7 @@ class Icon extends ImageStyle { displacement: options.displacement !== undefined ? options.displacement : [0, 0], rotateWithView: rotateWithView, + declutterMode: options.declutterMode, }); /** diff --git a/src/ol/style/Image.js b/src/ol/style/Image.js index 73a948341d..f42ed7a8ea 100644 --- a/src/ol/style/Image.js +++ b/src/ol/style/Image.js @@ -11,7 +11,7 @@ import {toSize} from '../size.js'; * @property {number} rotation Rotation. * @property {number|import("../size.js").Size} scale Scale. * @property {Array} displacement Displacement. - */ + * @property {"declutter"|"obstacle"|"none"|undefined} declutterMode Declutter mode: `declutter`, `obstacle`, 'none */ /** * @classdesc @@ -61,6 +61,12 @@ class ImageStyle { * @type {Array} */ this.displacement_ = options.displacement; + + /** + * @private + * @type {"declutter"|"obstacle"|"none"|undefined} + */ + this.declutterMode_ = options.declutterMode; } /** @@ -76,6 +82,7 @@ class ImageStyle { rotation: this.getRotation(), rotateWithView: this.getRotateWithView(), displacement: this.getDisplacement().slice(), + declutterMode: this.getDeclutterMode(), }); } @@ -132,6 +139,15 @@ class ImageStyle { return this.displacement_; } + /** + * Get the declutter mode of the shape + * @return {"declutter"|"obstacle"|"none"|undefined} Shape's declutter mode + * @api + */ + getDeclutterMode() { + return this.declutterMode_; + } + /** * Get the anchor point in pixels. The anchor determines the center point for the * symbolizer. diff --git a/src/ol/style/RegularShape.js b/src/ol/style/RegularShape.js index 920e40565e..e04f0bc579 100644 --- a/src/ol/style/RegularShape.js +++ b/src/ol/style/RegularShape.js @@ -31,6 +31,7 @@ import { * @property {boolean} [rotateWithView=false] Whether to rotate the shape with the view. * @property {number|import("../size.js").Size} [scale=1] Scale. Unless two dimensional scaling is required a better * result may be obtained with appropriate settings for `radius`, `radius1` and `radius2`. + * @property {"declutter"|"obstacle"|"none"|undefined} [declutterMode] Declutter mode */ /** @@ -69,6 +70,7 @@ class RegularShape extends ImageStyle { scale: options.scale !== undefined ? options.scale : 1, displacement: options.displacement !== undefined ? options.displacement : [0, 0], + declutterMode: options.declutterMode, }); /** @@ -159,6 +161,7 @@ class RegularShape extends ImageStyle { rotateWithView: this.getRotateWithView(), scale: Array.isArray(scale) ? scale.slice() : scale, displacement: this.getDisplacement().slice(), + declutterMode: this.getDeclutterMode(), }); style.setOpacity(this.getOpacity()); return style; diff --git a/test/browser/spec/ol/render/canvas/textbuilder.test.js b/test/browser/spec/ol/render/canvas/textbuilder.test.js index 9aae74124e..f2582d8e65 100644 --- a/test/browser/spec/ol/render/canvas/textbuilder.test.js +++ b/test/browser/spec/ol/render/canvas/textbuilder.test.js @@ -320,7 +320,7 @@ describe('ol.render.canvas.TextBuilder', function () { builder.drawText(feature.getGeometry(), feature); expect(builder.coordinates).to.have.length(2); expect(builder.instructions).to.have.length(3); - const geometryWidths = builder.instructions[1][24]; + const geometryWidths = builder.instructions[1][25]; expect(geometryWidths).to.have.length(1); expect(geometryWidths[0]).to.be(120); }); diff --git a/test/rendering/cases/layer-vector-decluttering-extended/expected.png b/test/rendering/cases/layer-vector-decluttering-extended/expected.png new file mode 100644 index 0000000000..58a7bf9cb4 Binary files /dev/null and b/test/rendering/cases/layer-vector-decluttering-extended/expected.png differ diff --git a/test/rendering/cases/layer-vector-decluttering-extended/main.js b/test/rendering/cases/layer-vector-decluttering-extended/main.js new file mode 100644 index 0000000000..9a6ee7c59f --- /dev/null +++ b/test/rendering/cases/layer-vector-decluttering-extended/main.js @@ -0,0 +1,191 @@ +import CircleStyle from '../../../../src/ol/style/Circle.js'; +import Feature from '../../../../src/ol/Feature.js'; +import Fill from '../../../../src/ol/style/Fill.js'; +import Map from '../../../../src/ol/Map.js'; +import Point from '../../../../src/ol/geom/Point.js'; +import Stroke from '../../../../src/ol/style/Stroke.js'; +import Style from '../../../../src/ol/style/Style.js'; +import Text from '../../../../src/ol/style/Text.js'; +import VectorLayer from '../../../../src/ol/layer/Vector.js'; +import VectorSource from '../../../../src/ol/source/Vector.js'; +import View from '../../../../src/ol/View.js'; + +const center = [1825927.7316762917, 6143091.089223046]; +const map = new Map({ + pixelRatio: 1, + target: 'map', + view: new View({ + center: center, + zoom: 12.7, + }), +}); + +const sourceBlue = new VectorSource(); +sourceBlue.addFeatures([ + new Feature({ + geometry: new Point([center[0] + 1000 + 540, center[1] + 900 - 600]), + text: 'top-blue', + }), +]); +// on-top blue circle. +// shows that objects (red layer) will not serve as obstacles for layers on-top. +map.addLayer( + new VectorLayer({ + zIndex: 4, + declutter: true, + source: sourceBlue, + style: function (feature) { + return new Style({ + image: new CircleStyle({ + radius: 10, + stroke: new Stroke({ + color: 'blue', + width: 8, + }), + declutterMode: 'declutter', + }), + text: new Text({ + text: feature.get('text'), + font: 'italic bold 18px Ubuntu', + textBaseline: 'bottom', + offsetY: -15, + }), + }); + }, + }) +); + +const sourceRed = new VectorSource(); +sourceRed.addFeatures([ + new Feature({ + geometry: new Point([center[0] + 1000, center[1] + 1000 - 200]), + text: 'c-red', + }), + new Feature({ + geometry: new Point([center[0] + 1000 - 540, center[1] + 1000]), + text: 'w-red', + }), + new Feature({ + geometry: new Point([center[0] + 1000 + 540, center[1] + 1000 - 400]), + text: 'e-red', + }), +]); +// red circles are always drawn, but serve as obstacles. +// however, they cannot serve as obstacles for layers on-top (blue layer). +// texts are decluttered against each other and the circles. +// circles are drawn on non-declutter executor, i.e. behind decluttered labels and objects. +map.addLayer( + new VectorLayer({ + zIndex: 3, + declutter: true, + source: sourceRed, + style: function (feature) { + return new Style({ + image: new CircleStyle({ + radius: 10, + stroke: new Stroke({ + color: 'red', + width: 8, + }), + declutterMode: 'obstacle', + }), + text: new Text({ + text: feature.get('text'), + font: 'italic bold 18px Ubuntu', + textBaseline: 'bottom', + offsetY: -15, + }), + }); + }, + }) +); + +const sourceOrange = new VectorSource(); +sourceOrange.addFeatures([ + new Feature({ + geometry: new Point([center[0], center[1]]), + text: 'c-orange', + }), + new Feature({ + geometry: new Point([center[0] - 540, center[1]]), + text: 'w-orange', + }), + new Feature({ + geometry: new Point([center[0] + 540, center[1]]), + text: 'e-orange', + }), +]); +// orange circles are always drawn. +// texts are decluttered against each other and the blue/red layer circles/texts. +map.addLayer( + new VectorLayer({ + zIndex: 2, + declutter: true, + source: sourceOrange, + style: function (feature) { + return new Style({ + image: new CircleStyle({ + radius: 15, + fill: new Fill({ + color: 'orange', + }), + declutterMode: 'none', + }), + text: new Text({ + text: feature.get('text'), + font: 'italic bold 18px Ubuntu', + textBaseline: 'bottom', + offsetX: -25, + offsetY: -17, + }), + }); + }, + }) +); + +const sourceCyan = new VectorSource(); +sourceCyan.addFeatures([ + new Feature({ + geometry: new Point([center[0] + 1000 - 700, center[1] - 100]), + text: 'w-cyan', + }), + new Feature({ + geometry: new Point([center[0] + 1000, center[1] - 400]), + text: 'c-cyan', + }), + new Feature({ + geometry: new Point([center[0] + 1000 + 700, center[1] - 700]), + text: 'e-cyan', + }), +]); +// cyan circles are always drawn. +// texts are decluttered against each others (and blue/red/orange layers). +// the circles of the orange layer and this layer are no obstactles for texts. +// the texts are decluttered and thus above the circles of the orange layer. +map.addLayer( + new VectorLayer({ + zIndex: 1, + declutter: true, + source: sourceCyan, + style: function (feature) { + return new Style({ + image: new CircleStyle({ + radius: 15, + fill: new Fill({ + color: 'cyan', + }), + declutterMode: 'none', + }), + text: new Text({ + text: feature.get('text'), + font: 'italic bold 18px Ubuntu', + textBaseline: 'middle', + textAlign: 'right', + offsetX: -19, + }), + }); + }, + }) +); + +render({tolerance: 0.007});