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