Merge pull request #12646 from ashchurova/fix-8136-hit-detection-custom-renderer

Support for hit detection in styles with custom rendering
This commit is contained in:
MoonE
2021-09-29 20:22:57 +02:00
committed by GitHub
7 changed files with 476 additions and 174 deletions
@@ -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"
---
<div id="map" class="map"></div>
+103
View File
@@ -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;
});
+2 -1
View File
@@ -15,8 +15,9 @@ class VectorContext {
* @param {import("../geom/SimpleGeometry.js").default} geometry Geometry. * @param {import("../geom/SimpleGeometry.js").default} geometry Geometry.
* @param {import("../Feature.js").FeatureLike} feature Feature. * @param {import("../Feature.js").FeatureLike} feature Feature.
* @param {Function} renderer Renderer. * @param {Function} renderer Renderer.
* @param {Function} hitDetectionRenderer Renderer.
*/ */
drawCustom(geometry, feature, renderer) {} drawCustom(geometry, feature, renderer, hitDetectionRenderer) {}
/** /**
* Render a geometry. * Render a geometry.
+60 -12
View File
@@ -247,15 +247,20 @@ class CanvasBuilder extends VectorContext {
* @param {import("../../geom/SimpleGeometry.js").default} geometry Geometry. * @param {import("../../geom/SimpleGeometry.js").default} geometry Geometry.
* @param {import("../../Feature.js").FeatureLike} feature Feature. * @param {import("../../Feature.js").FeatureLike} feature Feature.
* @param {Function} renderer Renderer. * @param {Function} renderer Renderer.
* @param {Function} hitDetectionRenderer Renderer.
*/ */
drawCustom(geometry, feature, renderer) { drawCustom(geometry, feature, renderer, hitDetectionRenderer) {
this.beginGeometry(geometry, feature); this.beginGeometry(geometry, feature);
const type = geometry.getType(); const type = geometry.getType();
const stride = geometry.getStride(); const stride = geometry.getStride();
const builderBegin = this.coordinates.length; const builderBegin = this.coordinates.length;
let flatCoordinates, builderEnd, builderEnds, builderEndss; let flatCoordinates, builderEnd, builderEnds, builderEndss;
let offset; let offset;
if (type == GeometryType.MULTI_POLYGON) {
switch (type) {
case GeometryType.MULTI_POLYGON:
flatCoordinates = flatCoordinates =
/** @type {import("../../geom/MultiPolygon.js").default} */ ( /** @type {import("../../geom/MultiPolygon.js").default} */ (
geometry geometry
@@ -285,10 +290,17 @@ class CanvasBuilder extends VectorContext {
renderer, renderer,
inflateMultiCoordinatesArray, inflateMultiCoordinatesArray,
]); ]);
} else if ( this.hitDetectionInstructions.push([
type == GeometryType.POLYGON || CanvasInstruction.CUSTOM,
type == GeometryType.MULTI_LINE_STRING builderBegin,
) { builderEndss,
geometry,
hitDetectionRenderer || renderer,
inflateMultiCoordinatesArray,
]);
break;
case GeometryType.POLYGON:
case GeometryType.MULTI_LINE_STRING:
builderEnds = []; builderEnds = [];
flatCoordinates = flatCoordinates =
type == GeometryType.POLYGON type == GeometryType.POLYGON
@@ -313,10 +325,17 @@ class CanvasBuilder extends VectorContext {
renderer, renderer,
inflateCoordinatesArray, inflateCoordinatesArray,
]); ]);
} else if ( this.hitDetectionInstructions.push([
type == GeometryType.LINE_STRING || CanvasInstruction.CUSTOM,
type == GeometryType.CIRCLE builderBegin,
) { builderEnds,
geometry,
hitDetectionRenderer || renderer,
inflateCoordinatesArray,
]);
break;
case GeometryType.LINE_STRING:
case GeometryType.CIRCLE:
flatCoordinates = geometry.getFlatCoordinates(); flatCoordinates = geometry.getFlatCoordinates();
builderEnd = this.appendFlatLineCoordinates( builderEnd = this.appendFlatLineCoordinates(
flatCoordinates, flatCoordinates,
@@ -334,9 +353,19 @@ class CanvasBuilder extends VectorContext {
renderer, renderer,
inflateCoordinates, inflateCoordinates,
]); ]);
} else if (type == GeometryType.MULTI_POINT) { this.hitDetectionInstructions.push([
CanvasInstruction.CUSTOM,
builderBegin,
builderEnd,
geometry,
hitDetectionRenderer || renderer,
inflateCoordinates,
]);
break;
case GeometryType.MULTI_POINT:
flatCoordinates = geometry.getFlatCoordinates(); flatCoordinates = geometry.getFlatCoordinates();
builderEnd = this.appendFlatPointCoordinates(flatCoordinates, stride); builderEnd = this.appendFlatPointCoordinates(flatCoordinates, stride);
if (builderEnd > builderBegin) { if (builderEnd > builderBegin) {
this.instructions.push([ this.instructions.push([
CanvasInstruction.CUSTOM, CanvasInstruction.CUSTOM,
@@ -346,11 +375,21 @@ class CanvasBuilder extends VectorContext {
renderer, renderer,
inflateCoordinates, inflateCoordinates,
]); ]);
this.hitDetectionInstructions.push([
CanvasInstruction.CUSTOM,
builderBegin,
builderEnd,
geometry,
hitDetectionRenderer || renderer,
inflateCoordinates,
]);
} }
} else if (type == GeometryType.POINT) { break;
case GeometryType.POINT:
flatCoordinates = geometry.getFlatCoordinates(); flatCoordinates = geometry.getFlatCoordinates();
this.coordinates.push(flatCoordinates[0], flatCoordinates[1]); this.coordinates.push(flatCoordinates[0], flatCoordinates[1]);
builderEnd = this.coordinates.length; builderEnd = this.coordinates.length;
this.instructions.push([ this.instructions.push([
CanvasInstruction.CUSTOM, CanvasInstruction.CUSTOM,
builderBegin, builderBegin,
@@ -358,6 +397,15 @@ class CanvasBuilder extends VectorContext {
geometry, geometry,
renderer, renderer,
]); ]);
this.hitDetectionInstructions.push([
CanvasInstruction.CUSTOM,
builderBegin,
builderEnd,
geometry,
hitDetectionRenderer || renderer,
]);
break;
default:
} }
this.endGeometry(feature); this.endGeometry(feature);
} }
+2 -1
View File
@@ -208,7 +208,8 @@ function renderGeometry(replayGroup, geometry, style, feature) {
replay.drawCustom( replay.drawCustom(
/** @type {import("../geom/SimpleGeometry.js").default} */ (geometry), /** @type {import("../geom/SimpleGeometry.js").default} */ (geometry),
feature, feature,
style.getRenderer() style.getRenderer(),
style.getHitDetectionRenderer()
); );
} }
+31
View File
@@ -49,6 +49,8 @@ import {assert} from '../asserts.js';
* @property {import("./Image.js").default} [image] Image style. * @property {import("./Image.js").default} [image] Image style.
* @property {RenderFunction} [renderer] Custom renderer. When configured, `fill`, `stroke` and `image` will be * @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. * 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("./Stroke.js").default} [stroke] Stroke style.
* @property {import("./Text.js").default} [text] Text style. * @property {import("./Text.js").default} [text] Text style.
* @property {number} [zIndex] Z index. * @property {number} [zIndex] Z index.
@@ -186,6 +188,15 @@ class Style {
*/ */
this.renderer_ = options.renderer !== undefined ? options.renderer : null; this.renderer_ = options.renderer !== undefined ? options.renderer : null;
/**
* @private
* @type {RenderFunction|null}
*/
this.hitDetectionRenderer_ =
options.hitDetectionRenderer !== undefined
? options.hitDetectionRenderer
: null;
/** /**
* @private * @private
* @type {import("./Stroke.js").default} * @type {import("./Stroke.js").default}
@@ -248,6 +259,26 @@ class Style {
this.renderer_ = renderer; 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. * Get the geometry to be rendered.
* @return {string|import("../geom/Geometry.js").default|GeometryFunction} * @return {string|import("../geom/Geometry.js").default|GeometryFunction}
@@ -236,6 +236,144 @@ describe('ol.render.canvas.BuilderGroup', function () {
expect(lineDashOffset).to.be(4); expect(lineDashOffset).to.be(4);
}); });
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<number>} [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 () { it('calls the renderer function configured for the style', function () {
const calls = []; const calls = [];
const style = new Style({ const style = new Style({
@@ -251,40 +389,7 @@ describe('ol.render.canvas.BuilderGroup', function () {
}); });
}, },
}); });
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); builder = new BuilderGroup(1, [-180, -90, 180, 90], 1, 1, true);
renderFeature(builder, point, style, 1); renderFeature(builder, point, style, 1);
renderFeature(builder, multipoint, style, 1); renderFeature(builder, multipoint, style, 1);
@@ -320,6 +425,7 @@ describe('ol.render.canvas.BuilderGroup', function () {
}); });
}); });
}); });
});
describe('ol.render.canvas.Builder', function () { describe('ol.render.canvas.Builder', function () {
describe('constructor', function () { describe('constructor', function () {