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

View File

@@ -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>

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;
});

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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()
);
}

View File

@@ -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}

View File

@@ -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<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 () {
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]);
});
});
});