diff --git a/examples/custom-hit-detection-renderer.html b/examples/custom-hit-detection-renderer.html new file mode 100644 index 0000000000..c373d7761f --- /dev/null +++ b/examples/custom-hit-detection-renderer.html @@ -0,0 +1,12 @@ +--- +layout: example.html +title: Custom Hit Detection Render +shortdesc: Example of a custom hit detection renderer. +docs: > + This example demonstrates the use of 'ol/style/Style' hitDetectionRender option function in + detecting if pointer is over a particular feature. + Move pointer over the label for Columbus Circle feature and see that only label is used in + hit detection. +tags: "circle, feature, vector, render, custom, hitDetectionRenderer" +--- +
\ No newline at end of file diff --git a/examples/custom-hit-detection-renderer.js b/examples/custom-hit-detection-renderer.js new file mode 100644 index 0000000000..0e52f54a0c --- /dev/null +++ b/examples/custom-hit-detection-renderer.js @@ -0,0 +1,103 @@ +import Feature from '../src/ol/Feature.js'; +import Map from '../src/ol/Map.js'; +import View from '../src/ol/View.js'; +import {Circle} from '../src/ol/geom.js'; +import {OSM, Vector as VectorSource} from '../src/ol/source.js'; +import {Style} from '../src/ol/style.js'; +import {Tile as TileLayer, Vector as VectorLayer} from '../src/ol/layer.js'; +import {fromLonLat} from '../src/ol/proj.js'; + +const columbusCircleCoords = fromLonLat([-73.98189, 40.76805]); +const labelTextStroke = 'rgba(120, 120, 120, 1)'; +const labelText = 'Columbus Circle'; + +let pointerOverFeature = null; + +const renderLabelText = (ctx, x, y, stroke) => { + ctx.fillStyle = 'rgba(255,0,0,1)'; + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = `bold 30px verdana`; + ctx.filter = 'drop-shadow(7px 7px 2px #e81)'; + ctx.fillText(labelText, x, y); + ctx.strokeText(labelText, x, y); +}; + +const circleFeature = new Feature({ + geometry: new Circle(columbusCircleCoords, 50), +}); + +circleFeature.set('label-color', labelTextStroke); + +circleFeature.setStyle( + new Style({ + renderer(coordinates, state) { + const [[x, y], [x1, y1]] = coordinates; + const ctx = state.context; + const dx = x1 - x; + const dy = y1 - y; + const radius = Math.sqrt(dx * dx + dy * dy); + + const innerRadius = 0; + const outerRadius = radius * 1.4; + + const gradient = ctx.createRadialGradient( + x, + y, + innerRadius, + x, + y, + outerRadius + ); + gradient.addColorStop(0, 'rgba(255,0,0,0)'); + gradient.addColorStop(0.6, 'rgba(255,0,0,0.2)'); + gradient.addColorStop(1, 'rgba(255,0,0,0.8)'); + ctx.beginPath(); + ctx.arc(x, y, radius, 0, 2 * Math.PI, true); + ctx.fillStyle = gradient; + ctx.fill(); + ctx.strokeStyle = 'rgba(255,0,0,1)'; + ctx.stroke(); + + renderLabelText(ctx, x, y, circleFeature.get('label-color')); + }, + hitDetectionRenderer(coordinates, state) { + const [x, y] = coordinates[0]; + const ctx = state.context; + renderLabelText(ctx, x, y, circleFeature.get('label-color')); + }, + }) +); + +const map = new Map({ + layers: [ + new TileLayer({ + source: new OSM(), + visible: true, + }), + new VectorLayer({ + source: new VectorSource({ + features: [circleFeature], + }), + }), + ], + target: 'map', + view: new View({ + center: columbusCircleCoords, + zoom: 19, + }), +}); + +map.on('pointermove', (evt) => { + const featureOver = map.forEachFeatureAtPixel(evt.pixel, (feature) => { + feature.set('label-color', 'rgba(255,255,255,1)'); + return feature; + }); + + if (pointerOverFeature && pointerOverFeature != featureOver) { + pointerOverFeature.set('label-color', labelTextStroke); + } + pointerOverFeature = featureOver; +}); diff --git a/src/ol/render/VectorContext.js b/src/ol/render/VectorContext.js index f4d477faf3..34a0803d45 100644 --- a/src/ol/render/VectorContext.js +++ b/src/ol/render/VectorContext.js @@ -15,8 +15,9 @@ class VectorContext { * @param {import("../geom/SimpleGeometry.js").default} geometry Geometry. * @param {import("../Feature.js").FeatureLike} feature Feature. * @param {Function} renderer Renderer. + * @param {Function} hitDetectionRenderer Renderer. */ - drawCustom(geometry, feature, renderer) {} + drawCustom(geometry, feature, renderer, hitDetectionRenderer) {} /** * Render a geometry. diff --git a/src/ol/render/canvas/Builder.js b/src/ol/render/canvas/Builder.js index 9735410470..fa4cfceb60 100644 --- a/src/ol/render/canvas/Builder.js +++ b/src/ol/render/canvas/Builder.js @@ -247,97 +247,104 @@ class CanvasBuilder extends VectorContext { * @param {import("../../geom/SimpleGeometry.js").default} geometry Geometry. * @param {import("../../Feature.js").FeatureLike} feature Feature. * @param {Function} renderer Renderer. + * @param {Function} hitDetectionRenderer Renderer. */ - drawCustom(geometry, feature, renderer) { + drawCustom(geometry, feature, renderer, hitDetectionRenderer) { this.beginGeometry(geometry, feature); + const type = geometry.getType(); const stride = geometry.getStride(); const builderBegin = this.coordinates.length; + let flatCoordinates, builderEnd, builderEnds, builderEndss; let offset; - if (type == GeometryType.MULTI_POLYGON) { - flatCoordinates = - /** @type {import("../../geom/MultiPolygon.js").default} */ ( - geometry - ).getOrientedFlatCoordinates(); - builderEndss = []; - const endss = - /** @type {import("../../geom/MultiPolygon.js").default} */ ( - geometry - ).getEndss(); - offset = 0; - for (let i = 0, ii = endss.length; i < ii; ++i) { - const myEnds = []; + + switch (type) { + case GeometryType.MULTI_POLYGON: + flatCoordinates = + /** @type {import("../../geom/MultiPolygon.js").default} */ ( + geometry + ).getOrientedFlatCoordinates(); + builderEndss = []; + const endss = + /** @type {import("../../geom/MultiPolygon.js").default} */ ( + geometry + ).getEndss(); + offset = 0; + for (let i = 0, ii = endss.length; i < ii; ++i) { + const myEnds = []; + offset = this.drawCustomCoordinates_( + flatCoordinates, + offset, + endss[i], + stride, + myEnds + ); + builderEndss.push(myEnds); + } + this.instructions.push([ + CanvasInstruction.CUSTOM, + builderBegin, + builderEndss, + geometry, + renderer, + inflateMultiCoordinatesArray, + ]); + this.hitDetectionInstructions.push([ + CanvasInstruction.CUSTOM, + builderBegin, + builderEndss, + geometry, + hitDetectionRenderer || renderer, + inflateMultiCoordinatesArray, + ]); + break; + case GeometryType.POLYGON: + case GeometryType.MULTI_LINE_STRING: + builderEnds = []; + flatCoordinates = + type == GeometryType.POLYGON + ? /** @type {import("../../geom/Polygon.js").default} */ ( + geometry + ).getOrientedFlatCoordinates() + : geometry.getFlatCoordinates(); offset = this.drawCustomCoordinates_( flatCoordinates, - offset, - endss[i], + 0, + /** @type {import("../../geom/Polygon.js").default|import("../../geom/MultiLineString.js").default} */ ( + geometry + ).getEnds(), stride, - myEnds + builderEnds + ); + this.instructions.push([ + CanvasInstruction.CUSTOM, + builderBegin, + builderEnds, + geometry, + renderer, + inflateCoordinatesArray, + ]); + this.hitDetectionInstructions.push([ + CanvasInstruction.CUSTOM, + builderBegin, + builderEnds, + geometry, + hitDetectionRenderer || renderer, + inflateCoordinatesArray, + ]); + break; + case GeometryType.LINE_STRING: + case GeometryType.CIRCLE: + flatCoordinates = geometry.getFlatCoordinates(); + builderEnd = this.appendFlatLineCoordinates( + flatCoordinates, + 0, + flatCoordinates.length, + stride, + false, + false ); - builderEndss.push(myEnds); - } - this.instructions.push([ - CanvasInstruction.CUSTOM, - builderBegin, - builderEndss, - geometry, - renderer, - inflateMultiCoordinatesArray, - ]); - } else if ( - type == GeometryType.POLYGON || - type == GeometryType.MULTI_LINE_STRING - ) { - builderEnds = []; - flatCoordinates = - type == GeometryType.POLYGON - ? /** @type {import("../../geom/Polygon.js").default} */ ( - geometry - ).getOrientedFlatCoordinates() - : geometry.getFlatCoordinates(); - offset = this.drawCustomCoordinates_( - flatCoordinates, - 0, - /** @type {import("../../geom/Polygon.js").default|import("../../geom/MultiLineString.js").default} */ ( - geometry - ).getEnds(), - stride, - builderEnds - ); - this.instructions.push([ - CanvasInstruction.CUSTOM, - builderBegin, - builderEnds, - geometry, - renderer, - inflateCoordinatesArray, - ]); - } else if ( - type == GeometryType.LINE_STRING || - type == GeometryType.CIRCLE - ) { - flatCoordinates = geometry.getFlatCoordinates(); - builderEnd = this.appendFlatLineCoordinates( - flatCoordinates, - 0, - flatCoordinates.length, - stride, - false, - false - ); - this.instructions.push([ - CanvasInstruction.CUSTOM, - builderBegin, - builderEnd, - geometry, - renderer, - inflateCoordinates, - ]); - } else if (type == GeometryType.MULTI_POINT) { - flatCoordinates = geometry.getFlatCoordinates(); - builderEnd = this.appendFlatPointCoordinates(flatCoordinates, stride); - if (builderEnd > builderBegin) { this.instructions.push([ CanvasInstruction.CUSTOM, builderBegin, @@ -346,18 +353,59 @@ class CanvasBuilder extends VectorContext { renderer, inflateCoordinates, ]); - } - } else if (type == GeometryType.POINT) { - flatCoordinates = geometry.getFlatCoordinates(); - this.coordinates.push(flatCoordinates[0], flatCoordinates[1]); - builderEnd = this.coordinates.length; - this.instructions.push([ - CanvasInstruction.CUSTOM, - builderBegin, - builderEnd, - geometry, - renderer, - ]); + this.hitDetectionInstructions.push([ + CanvasInstruction.CUSTOM, + builderBegin, + builderEnd, + geometry, + hitDetectionRenderer || renderer, + inflateCoordinates, + ]); + break; + case GeometryType.MULTI_POINT: + flatCoordinates = geometry.getFlatCoordinates(); + builderEnd = this.appendFlatPointCoordinates(flatCoordinates, stride); + + if (builderEnd > builderBegin) { + this.instructions.push([ + CanvasInstruction.CUSTOM, + builderBegin, + builderEnd, + geometry, + renderer, + inflateCoordinates, + ]); + this.hitDetectionInstructions.push([ + CanvasInstruction.CUSTOM, + builderBegin, + builderEnd, + geometry, + hitDetectionRenderer || renderer, + inflateCoordinates, + ]); + } + break; + case GeometryType.POINT: + flatCoordinates = geometry.getFlatCoordinates(); + this.coordinates.push(flatCoordinates[0], flatCoordinates[1]); + builderEnd = this.coordinates.length; + + this.instructions.push([ + CanvasInstruction.CUSTOM, + builderBegin, + builderEnd, + geometry, + renderer, + ]); + this.hitDetectionInstructions.push([ + CanvasInstruction.CUSTOM, + builderBegin, + builderEnd, + geometry, + hitDetectionRenderer || renderer, + ]); + break; + default: } this.endGeometry(feature); } diff --git a/src/ol/renderer/vector.js b/src/ol/renderer/vector.js index 32d2e99ba7..3193bd5b05 100644 --- a/src/ol/renderer/vector.js +++ b/src/ol/renderer/vector.js @@ -208,7 +208,8 @@ function renderGeometry(replayGroup, geometry, style, feature) { replay.drawCustom( /** @type {import("../geom/SimpleGeometry.js").default} */ (geometry), feature, - style.getRenderer() + style.getRenderer(), + style.getHitDetectionRenderer() ); } diff --git a/src/ol/style/Style.js b/src/ol/style/Style.js index 2dc137c6c1..07e603114a 100644 --- a/src/ol/style/Style.js +++ b/src/ol/style/Style.js @@ -49,6 +49,8 @@ import {assert} from '../asserts.js'; * @property {import("./Image.js").default} [image] Image style. * @property {RenderFunction} [renderer] Custom renderer. When configured, `fill`, `stroke` and `image` will be * ignored, and the provided function will be called with each render frame for each geometry. + * @property {RenderFunction} [hitDetectionRenderer] Custom renderer for hit detection. If provided will be used + * in hit detection rendering. * @property {import("./Stroke.js").default} [stroke] Stroke style. * @property {import("./Text.js").default} [text] Text style. * @property {number} [zIndex] Z index. @@ -186,6 +188,15 @@ class Style { */ this.renderer_ = options.renderer !== undefined ? options.renderer : null; + /** + * @private + * @type {RenderFunction|null} + */ + this.hitDetectionRenderer_ = + options.hitDetectionRenderer !== undefined + ? options.hitDetectionRenderer + : null; + /** * @private * @type {import("./Stroke.js").default} @@ -248,6 +259,26 @@ class Style { this.renderer_ = renderer; } + /** + * Sets a custom renderer function for this style used + * in hit detection. + * @param {RenderFunction|null} renderer Custom renderer function. + * @api + */ + setHitDetectionRenderer(renderer) { + this.hitDetectionRenderer_ = renderer; + } + + /** + * Get the custom renderer function that was configured with + * {@link #setHitDetectionRenderer} or the `hitDetectionRenderer` constructor option. + * @return {RenderFunction|null} Custom renderer function. + * @api + */ + getHitDetectionRenderer() { + return this.hitDetectionRenderer_; + } + /** * Get the geometry to be rendered. * @return {string|import("../geom/Geometry.js").default|GeometryFunction} diff --git a/test/browser/spec/ol/renderer/canvas/builder.test.js b/test/browser/spec/ol/renderer/canvas/builder.test.js index 433f95d163..645d314725 100644 --- a/test/browser/spec/ol/renderer/canvas/builder.test.js +++ b/test/browser/spec/ol/renderer/canvas/builder.test.js @@ -236,87 +236,193 @@ describe('ol.render.canvas.BuilderGroup', function () { expect(lineDashOffset).to.be(4); }); - it('calls the renderer function configured for the style', function () { - const calls = []; - const style = new Style({ - renderer: function (coords, state) { - calls.push({ - coords: coords, - geometry: state.geometry, - feature: state.feature, - context: state.context, - pixelRatio: state.pixelRatio, - rotation: state.rotation, - resolution: state.resolution, - }); - }, + describe('use renderer and hitDetectionRenderer defined in style', function () { + let point, multipoint, linestring, multilinestring; + let polygon, multipolygon, geometrycollection; + + /** + * @param {BuilderGroup} builder The builder to get instructions from. + * @param {number} [pixelRatio] The pixel ratio. + * @param {boolean} [overlaps] Whether there is overlaps. + * @param {Array} [coordinate] Used for hit detection. + */ + function executeHitDetectionForCoordinate( + builder, + pixelRatio, + overlaps, + coordinate + ) { + const executor = new ExecutorGroup( + [-180, -90, 180, 90], + 1, + pixelRatio || 1, + !!overlaps, + builder.finish() + ); + + executor.execute(context, 1, transform, 0, false); + executor.forEachFeatureAtCoordinate(coordinate, 1, 0, 1, () => {}); + } + + beforeEach(function () { + point = new Feature(new Point([45, 90])); + multipoint = new Feature( + new MultiPoint([ + [45, 90], + [90, 45], + ]) + ); + linestring = new Feature( + new LineString([ + [45, 90], + [45, 45], + [90, 45], + ]) + ); + multilinestring = new Feature( + new MultiLineString([ + linestring.getGeometry().getCoordinates(), + linestring.getGeometry().getCoordinates(), + ]) + ); + polygon = feature1; + multipolygon = new Feature( + new MultiPolygon([ + polygon.getGeometry().getCoordinates(), + polygon.getGeometry().getCoordinates(), + ]) + ); + geometrycollection = new Feature( + new GeometryCollection([ + point.getGeometry(), + linestring.getGeometry(), + polygon.getGeometry(), + ]) + ); + }); + it('calls the renderer function in hit detection', function () { + const calls = []; + const style = new Style({ + renderer: function (coords, state) { + calls.push({ + coords: coords, + geometry: state.geometry, + feature: state.feature, + context: state.context, + pixelRatio: state.pixelRatio, + rotation: state.rotation, + resolution: state.resolution, + }); + }, + }); + + builder = new BuilderGroup(1, [-180, -90, 180, 90], 1, 1, true); + renderFeature(builder, point, style, 1); + renderFeature(builder, multipoint, style, 1); + renderFeature(builder, linestring, style, 1); + renderFeature(builder, multilinestring, style, 1); + renderFeature(builder, polygon, style, 1); + renderFeature(builder, multipolygon, style, 1); + renderFeature(builder, geometrycollection, style, 1); + scaleTransform(transform, 0.1, 0.1); + executeHitDetectionForCoordinate(builder, 1, true, [45, 90]); + + // since renderer will be used for rendering and hit detection + // expect calls.length to be ass twice was in rendering + expect(calls.length).to.be(18); + }); + + it('calls the hit detection renderer in hit detection', function () { + const calls = []; + const hitDetectionCalls = []; + const style = new Style({ + renderer: function (coords, state) { + calls.push({ + coords: coords, + geometry: state.geometry, + feature: state.feature, + context: state.context, + pixelRatio: state.pixelRatio, + rotation: state.rotation, + resolution: state.resolution, + }); + }, + hitDetectionRenderer: function (coords, state) { + hitDetectionCalls.push({ + coords: coords, + geometry: state.geometry, + feature: state.feature, + context: state.context, + pixelRatio: state.pixelRatio, + rotation: state.rotation, + resolution: state.resolution, + }); + }, + }); + + builder = new BuilderGroup(1, [-180, -90, 180, 90], 1, 1, true); + renderFeature(builder, point, style, 1); + renderFeature(builder, multipoint, style, 1); + renderFeature(builder, linestring, style, 1); + renderFeature(builder, multilinestring, style, 1); + renderFeature(builder, polygon, style, 1); + renderFeature(builder, multipolygon, style, 1); + renderFeature(builder, geometrycollection, style, 1); + scaleTransform(transform, 0.1, 0.1); + executeHitDetectionForCoordinate(builder, 1, true, [45, 90]); + expect(calls.length).to.be(9); + expect(hitDetectionCalls.length).to.be(9); + }); + + it('calls the renderer function configured for the style', function () { + const calls = []; + const style = new Style({ + renderer: function (coords, state) { + calls.push({ + coords: coords, + geometry: state.geometry, + feature: state.feature, + context: state.context, + pixelRatio: state.pixelRatio, + rotation: state.rotation, + resolution: state.resolution, + }); + }, + }); + + builder = new BuilderGroup(1, [-180, -90, 180, 90], 1, 1, true); + renderFeature(builder, point, style, 1); + renderFeature(builder, multipoint, style, 1); + renderFeature(builder, linestring, style, 1); + renderFeature(builder, multilinestring, style, 1); + renderFeature(builder, polygon, style, 1); + renderFeature(builder, multipolygon, style, 1); + renderFeature(builder, geometrycollection, style, 1); + scaleTransform(transform, 0.1, 0.1); + execute(builder, 1, true); + expect(calls.length).to.be(9); + expect(calls[0].geometry).to.be(point.getGeometry()); + expect(calls[0].feature).to.be(point); + expect(calls[0].context).to.be(context); + expect(calls[0].pixelRatio).to.be(1); + expect(calls[0].rotation).to.be(0); + expect(calls[0].resolution).to.be(1); + expect(calls[0].coords).to.eql([4.5, 9]); + expect(calls[1].feature).to.be(multipoint); + expect(calls[1].coords[0]).to.eql([4.5, 9]); + expect(calls[2].feature).to.be(linestring); + expect(calls[2].coords[0]).to.eql([4.5, 9]); + expect(calls[3].feature).to.be(multilinestring); + expect(calls[3].coords[0][0]).to.eql([4.5, 9]); + expect(calls[4].feature).to.be(polygon); + expect(calls[4].coords[0][0]).to.eql([-9, -4.5]); + expect(calls[5].feature).to.be(multipolygon); + expect(calls[5].coords[0][0][0]).to.eql([-9, -4.5]); + expect(calls[6].feature).to.be(geometrycollection); + expect(calls[6].geometry.getCoordinates()).to.eql([45, 90]); + expect(calls[7].geometry.getCoordinates()[0]).to.eql([45, 90]); + expect(calls[8].geometry.getCoordinates()[0][0]).to.eql([-90, -45]); }); - const point = new Feature(new Point([45, 90])); - const multipoint = new Feature( - new MultiPoint([ - [45, 90], - [90, 45], - ]) - ); - const linestring = new Feature( - new LineString([ - [45, 90], - [45, 45], - [90, 45], - ]) - ); - const multilinestring = new Feature( - new MultiLineString([ - linestring.getGeometry().getCoordinates(), - linestring.getGeometry().getCoordinates(), - ]) - ); - const polygon = feature1; - const multipolygon = new Feature( - new MultiPolygon([ - polygon.getGeometry().getCoordinates(), - polygon.getGeometry().getCoordinates(), - ]) - ); - const geometrycollection = new Feature( - new GeometryCollection([ - point.getGeometry(), - linestring.getGeometry(), - polygon.getGeometry(), - ]) - ); - builder = new BuilderGroup(1, [-180, -90, 180, 90], 1, 1, true); - renderFeature(builder, point, style, 1); - renderFeature(builder, multipoint, style, 1); - renderFeature(builder, linestring, style, 1); - renderFeature(builder, multilinestring, style, 1); - renderFeature(builder, polygon, style, 1); - renderFeature(builder, multipolygon, style, 1); - renderFeature(builder, geometrycollection, style, 1); - scaleTransform(transform, 0.1, 0.1); - execute(builder, 1, true); - expect(calls.length).to.be(9); - expect(calls[0].geometry).to.be(point.getGeometry()); - expect(calls[0].feature).to.be(point); - expect(calls[0].context).to.be(context); - expect(calls[0].pixelRatio).to.be(1); - expect(calls[0].rotation).to.be(0); - expect(calls[0].resolution).to.be(1); - expect(calls[0].coords).to.eql([4.5, 9]); - expect(calls[1].feature).to.be(multipoint); - expect(calls[1].coords[0]).to.eql([4.5, 9]); - expect(calls[2].feature).to.be(linestring); - expect(calls[2].coords[0]).to.eql([4.5, 9]); - expect(calls[3].feature).to.be(multilinestring); - expect(calls[3].coords[0][0]).to.eql([4.5, 9]); - expect(calls[4].feature).to.be(polygon); - expect(calls[4].coords[0][0]).to.eql([-9, -4.5]); - expect(calls[5].feature).to.be(multipolygon); - expect(calls[5].coords[0][0][0]).to.eql([-9, -4.5]); - expect(calls[6].feature).to.be(geometrycollection); - expect(calls[6].geometry.getCoordinates()).to.eql([45, 90]); - expect(calls[7].geometry.getCoordinates()[0]).to.eql([45, 90]); - expect(calls[8].geometry.getCoordinates()[0][0]).to.eql([-90, -45]); }); }); });