diff --git a/examples/canvas-hit-detection.html b/examples/canvas-hit-detection.html
new file mode 100644
index 0000000000..fc4bda69a7
--- /dev/null
+++ b/examples/canvas-hit-detection.html
@@ -0,0 +1,24 @@
+
+
+
+ OpenLayers Canvas Hit Detection Example
+
+
+
+
+
+
+ Feature Hit Detection with Canvas
+
+ Demonstrates detection of feature hits with the canvas renderer.
+
+
+
+
+
+
diff --git a/examples/canvas-hit-detection.js b/examples/canvas-hit-detection.js
new file mode 100644
index 0000000000..b45562eab0
--- /dev/null
+++ b/examples/canvas-hit-detection.js
@@ -0,0 +1,88 @@
+
+// create some sample features
+var Feature = OpenLayers.Feature.Vector;
+var Geometry = OpenLayers.Geometry;
+var features = [
+ new Feature(new Geometry.Point(-90, 45)),
+ new Feature(
+ new Geometry.Point(0, 45),
+ {cls: "one"}
+ ),
+ new Feature(
+ new Geometry.Point(90, 45),
+ {cls: "two"}
+ ),
+ new Feature(
+ Geometry.fromWKT("LINESTRING(-110 -60, -80 -40, -50 -60, -20 -40)")
+ ),
+ new Feature(
+ Geometry.fromWKT("POLYGON((20 -20, 110 -20, 110 -80, 20 -80, 20 -20), (40 -40, 90 -40, 90 -60, 40 -60, 40 -40))")
+ )
+];
+
+// create rule based styles
+var Rule = OpenLayers.Rule;
+var Filter = OpenLayers.Filter;
+var style = new OpenLayers.Style({
+ pointRadius: 10,
+ strokeWidth: 2,
+ strokeOpacity: 0.7,
+ strokeColor: "navy",
+ fillColor: "#ffcc66",
+ fillOpacity: 1
+}, {
+ rules: [
+ new Rule({
+ filter: new Filter.Comparison({
+ type: "==",
+ property: "cls",
+ value: "one"
+ }),
+ symbolizer: {
+ externalGraphic: "../img/marker-blue.png"
+ }
+ }),
+ new Rule({
+ filter: new Filter.Comparison({
+ type: "==",
+ property: "cls",
+ value: "two"
+ }),
+ symbolizer: {
+ externalGraphic: "../img/marker-green.png"
+ }
+ }),
+ new Rule({
+ elseFilter: true,
+ symbolizer: {
+ graphicName: "circle"
+ }
+ })
+ ]
+});
+
+var layer = new OpenLayers.Layer.Vector(null, {
+ styleMap: new OpenLayers.StyleMap({
+ "default": style,
+ select: {
+ fillColor: "red",
+ pointRadius: 13,
+ strokeColor: "yellow",
+ strokeWidth: 3
+ }
+ }),
+ isBaseLayer: true,
+ renderers: ["Canvas"]
+});
+layer.addFeatures(features);
+
+var map = new OpenLayers.Map({
+ div: "map",
+ layers: [layer],
+ center: new OpenLayers.LonLat(0, 0),
+ zoom: 0
+});
+
+var select = new OpenLayers.Control.SelectFeature(layer, {hover: true});
+map.addControl(select);
+select.activate();
diff --git a/examples/canvas-inspector.html b/examples/canvas-inspector.html
new file mode 100644
index 0000000000..8244dc512d
--- /dev/null
+++ b/examples/canvas-inspector.html
@@ -0,0 +1,52 @@
+
+
+
+ OpenLayers Canvas Inspector
+
+
+
+
+
+
+
+
+ Canvas Inspector
+
+ Displays pixel values for canvas context.
+
+
+
+
+
+
+
+
+
diff --git a/examples/canvas-inspector.js b/examples/canvas-inspector.js
new file mode 100644
index 0000000000..064b4d5b56
--- /dev/null
+++ b/examples/canvas-inspector.js
@@ -0,0 +1,91 @@
+
+var features = [
+
+ new OpenLayers.Feature.Vector(
+ OpenLayers.Geometry.fromWKT(
+ "LINESTRING(-90 90, 90 -90)"
+ ),
+ {color: "#0f0000"}
+ ),
+
+ new OpenLayers.Feature.Vector(
+ OpenLayers.Geometry.fromWKT(
+ "LINESTRING(100 50, -100 -50)"
+ ),
+ {color: "#00ff00"}
+ )
+
+];
+
+var layer = new OpenLayers.Layer.Vector(null, {
+ styleMap: new OpenLayers.StyleMap({
+ strokeWidth: 3,
+ strokeColor: "${color}"
+ }),
+ isBaseLayer: true,
+ renderers: ["Canvas"],
+ rendererOptions: {hitDetection: true}
+});
+layer.addFeatures(features);
+
+var map = new OpenLayers.Map({
+ div: "map",
+ layers: [layer],
+ center: new OpenLayers.LonLat(0, 0),
+ zoom: 0
+});
+
+var xOff = 2, yOff = 2;
+
+var rows = 1 + (2 * yOff);
+var cols = 1 + (2 * xOff);
+
+var template = new jugl.Template("template");
+template.process({
+ clone: true,
+ parent: "inspector",
+ context: {
+ rows: rows,
+ cols: cols
+ }
+});
+
+function isDark(r, g, b, a) {
+ a = a / 255;
+ var da = 1 - a;
+ // convert color values to decimal (assume white background)
+ r = (a * r / 255) + da;
+ g = (a * g / 255) + da;
+ b = (a * b / 255) + da;
+ // use w3C brightness measure
+ var brightness = (r * 0.299) + (g * 0.587) + (b * 0.144);
+ return brightness < 0.5;
+}
+
+var context = layer.renderer.canvas; //layer.renderer.hitContext;
+var size = map.getSize();
+map.events.on({
+ mousemove: function(event) {
+ var x = event.xy.x - 1; // TODO: fix this elsewhere
+ var y = event.xy.y;
+ if ((x >= xOff) && (x < size.w - xOff) && (y >= yOff) && (y < size.h - yOff)) {
+ var data = context.getImageData(x - xOff, y - yOff, rows, cols).data;
+ var offset, red, green, blue, alpha, cell;
+ for (var i=0; iG: " + green + "
B: " + blue + "
A: " + alpha;
+ cell.style.backgroundColor = "rgba(" + red + ", " + green + ", " + blue + ", " + (alpha / 255) + ")";
+ cell.style.color = isDark(red, green, blue, alpha) ? "#ffffff" : "#000000";
+ }
+ }
+ }
+ }
+});
+
+
diff --git a/lib/OpenLayers/Layer/Vector.js b/lib/OpenLayers/Layer/Vector.js
index d395d0e067..d09a183dc3 100644
--- a/lib/OpenLayers/Layer/Vector.js
+++ b/lib/OpenLayers/Layer/Vector.js
@@ -843,9 +843,17 @@ OpenLayers.Layer.Vector = OpenLayers.Class(OpenLayers.Layer, {
if (!this.renderer) {
OpenLayers.Console.error(OpenLayers.i18n("getFeatureError"));
return null;
- }
+ }
+ var feature = null;
var featureId = this.renderer.getFeatureIdFromEvent(evt);
- return this.getFeatureById(featureId);
+ if (featureId) {
+ if (typeof featureId === "string") {
+ feature = this.getFeatureById(featureId);
+ } else {
+ feature = featureId;
+ }
+ }
+ return feature;
},
/**
diff --git a/lib/OpenLayers/Renderer.js b/lib/OpenLayers/Renderer.js
index 4a48dfe0a4..500e120b26 100644
--- a/lib/OpenLayers/Renderer.js
+++ b/lib/OpenLayers/Renderer.js
@@ -83,6 +83,7 @@ OpenLayers.Renderer = OpenLayers.Class({
*/
initialize: function(containerID, options) {
this.container = OpenLayers.Util.getElement(containerID);
+ OpenLayers.Util.extend(this, options);
},
/**
diff --git a/lib/OpenLayers/Renderer/Canvas.js b/lib/OpenLayers/Renderer/Canvas.js
index 3a5de7ce56..3f885ebe22 100644
--- a/lib/OpenLayers/Renderer/Canvas.js
+++ b/lib/OpenLayers/Renderer/Canvas.js
@@ -15,6 +15,22 @@
* -
*/
OpenLayers.Renderer.Canvas = OpenLayers.Class(OpenLayers.Renderer, {
+
+ /**
+ * APIProperty: hitDetection
+ * {Boolean} Allow for hit detection of features. Default is true.
+ */
+ hitDetection: true,
+
+ /**
+ * Property: hitOverflow
+ * {Number} The method for converting feature identifiers to color values
+ * supports 16777215 sequential values. Two features cannot be
+ * predictably detected if their identifiers differ by more than this
+ * value. The hitOverflow allows for bigger numbers (but the
+ * difference in values is still limited).
+ */
+ hitOverflow: 0,
/**
* Property: canvas
@@ -32,14 +48,21 @@ OpenLayers.Renderer.Canvas = OpenLayers.Class(OpenLayers.Renderer, {
* Constructor: OpenLayers.Renderer.Canvas
*
* Parameters:
- * containerID - {}
+ * containerID - {}
+ * options - {Object} Optional properties to be set on the renderer.
*/
- initialize: function(containerID) {
+ initialize: function(containerID, options) {
OpenLayers.Renderer.prototype.initialize.apply(this, arguments);
this.root = document.createElement("canvas");
this.container.appendChild(this.root);
this.canvas = this.root.getContext("2d");
this.features = {};
+ if (this.hitDetection) {
+ this.hitCanvas = document.createElement("canvas");
+ this.hitContext = this.hitCanvas.getContext("2d");
+ this.hitGraphicCanvas = document.createElement("canvas");
+ this.hitGraphicContext = this.hitGraphicCanvas.getContext("2d");
+ }
},
/**
@@ -78,11 +101,24 @@ OpenLayers.Renderer.Canvas = OpenLayers.Class(OpenLayers.Renderer, {
*/
setSize: function(size) {
this.size = size.clone();
- this.root.style.width = size.w + "px";
- this.root.style.height = size.h + "px";
- this.root.width = size.w;
- this.root.height = size.h;
+ var root = this.root;
+ root.style.width = size.w + "px";
+ root.style.height = size.h + "px";
+ root.width = size.w;
+ root.height = size.h;
this.resolution = null;
+ if (this.hitDetection) {
+ var hitCanvas = this.hitCanvas;
+ hitCanvas.style.width = size.w + "px";
+ hitCanvas.style.height = size.h + "px";
+ hitCanvas.width = size.w;
+ hitCanvas.height = size.h;
+ var hitGraphicCanvas = this.hitGraphicCanvas;
+ hitGraphicCanvas.style.width = size.w + "px";
+ hitGraphicCanvas.style.height = size.h + "px";
+ hitGraphicCanvas.width = size.w;
+ hitGraphicCanvas.height = size.h;
+ }
},
/**
@@ -112,29 +148,29 @@ OpenLayers.Renderer.Canvas = OpenLayers.Class(OpenLayers.Renderer, {
* geometry - {}
* style - {Object}
*/
- drawGeometry: function(geometry, style) {
+ drawGeometry: function(geometry, style, featureId) {
var className = geometry.CLASS_NAME;
if ((className == "OpenLayers.Geometry.Collection") ||
(className == "OpenLayers.Geometry.MultiPoint") ||
(className == "OpenLayers.Geometry.MultiLineString") ||
(className == "OpenLayers.Geometry.MultiPolygon")) {
for (var i = 0; i < geometry.components.length; i++) {
- this.drawGeometry(geometry.components[i], style);
+ this.drawGeometry(geometry.components[i], style, featureId);
}
return;
}
switch (geometry.CLASS_NAME) {
case "OpenLayers.Geometry.Point":
- this.drawPoint(geometry, style);
+ this.drawPoint(geometry, style, featureId);
break;
case "OpenLayers.Geometry.LineString":
- this.drawLineString(geometry, style);
+ this.drawLineString(geometry, style, featureId);
break;
case "OpenLayers.Geometry.LinearRing":
- this.drawLinearRing(geometry, style);
+ this.drawLinearRing(geometry, style, featureId);
break;
case "OpenLayers.Geometry.Polygon":
- this.drawPolygon(geometry, style);
+ this.drawPolygon(geometry, style, featureId);
break;
default:
break;
@@ -148,37 +184,70 @@ OpenLayers.Renderer.Canvas = OpenLayers.Class(OpenLayers.Renderer, {
* Parameters:
* geometry - {}
* style - {Object}
+ * featureId - {String}
*/
- drawExternalGraphic: function(pt, style) {
- var img = new Image();
-
- if(style.graphicTitle) {
- img.title=style.graphicTitle;
- }
+ drawExternalGraphic: function(pt, style, featureId) {
+ var img = new Image();
+
+ if (style.graphicTitle) {
+ img.title = style.graphicTitle;
+ }
- var width = style.graphicWidth || style.graphicHeight;
- var height = style.graphicHeight || style.graphicWidth;
- width = width ? width : style.pointRadius*2;
- height = height ? height : style.pointRadius*2;
- var xOffset = (style.graphicXOffset != undefined) ?
+ var width = style.graphicWidth || style.graphicHeight;
+ var height = style.graphicHeight || style.graphicWidth;
+ width = width ? width : style.pointRadius * 2;
+ height = height ? height : style.pointRadius * 2;
+ var xOffset = (style.graphicXOffset != undefined) ?
style.graphicXOffset : -(0.5 * width);
- var yOffset = (style.graphicYOffset != undefined) ?
+ var yOffset = (style.graphicYOffset != undefined) ?
style.graphicYOffset : -(0.5 * height);
-
- var context = { img: img,
- x: (pt[0]+xOffset),
- y: (pt[1]+yOffset),
- width: width,
- height: height,
- opacity: style.graphicOpacity || style.fillOpacity,
- canvas: this.canvas };
- img.onload = OpenLayers.Function.bind( function() {
- this.canvas.globalAlpha = this.opacity;
- this.canvas.drawImage(this.img, this.x,
- this.y, this.width, this.height);
- }, context);
- img.src = style.externalGraphic;
+ var x = pt[0] + xOffset;
+ var y = pt[1] + yOffset;
+
+ var numRows = this.root.width;
+ var numCols = this.root.height;
+
+ var opacity = style.graphicOpacity || style.fillOpacity;
+
+ var rgb = this.featureIdToRGB(featureId);
+ var red = rgb[0];
+ var green = rgb[1];
+ var blue = rgb[2];
+
+ var onLoad = function() {
+ // TODO: check that we haven't moved
+ var canvas = this.canvas;
+ canvas.globalAlpha = opacity;
+ canvas.drawImage(
+ img, x, y, width, height
+ );
+ if (this.hitDetection) {
+ var hitGraphicContext = this.hitGraphicContext;
+ var hitContext = this.hitContext;
+ hitGraphicContext.clearRect(0, 0, numRows, numCols);
+ hitGraphicContext.drawImage(
+ img, 0, 0, width, height
+ );
+ var imagePixels = hitGraphicContext.getImageData(0, 0, width, height).data;
+ var indexData = hitContext.createImageData(width, height);
+ var indexPixels = indexData.data;
+ var pixelIndex;
+ for (var i=0, len=imagePixels.length; i 0) {
+ indexData[i] = red;
+ indexPixels[i+1] = green;
+ indexPixels[i+2] = blue;
+ indexPixels[i+3] = 255;
+ }
+ }
+ hitContext.putImageData(indexData, x, y);
+ }
+ };
+
+ img.onload = OpenLayers.Function.bind(onLoad, this);
+ img.src = style.externalGraphic;
},
/**
@@ -190,10 +259,10 @@ OpenLayers.Renderer.Canvas = OpenLayers.Class(OpenLayers.Renderer, {
* style - {Object} Symbolizer hash
*/
setCanvasStyle: function(type, style) {
- if (type == "fill") {
+ if (type === "fill") {
this.canvas.globalAlpha = style['fillOpacity'];
this.canvas.fillStyle = style['fillColor'];
- } else if (type == "stroke") {
+ } else if (type === "stroke") {
this.canvas.globalAlpha = style['strokeOpacity'];
this.canvas.strokeStyle = style['strokeColor'];
this.canvas.lineWidth = style['strokeWidth'];
@@ -202,6 +271,72 @@ OpenLayers.Renderer.Canvas = OpenLayers.Class(OpenLayers.Renderer, {
this.canvas.lineWidth = 1;
}
},
+
+ /**
+ * Method: featureIdToHex
+ * Convert a feature ID string into an RGB hex string.
+ *
+ * Parameters:
+ * featureId - {String} Feature id
+ *
+ * Returns:
+ * {String} RGB hex string.
+ */
+ featureIdToHex: function(featureId) {
+ var id = Number(featureId.split("_").pop()) + 1; // zero for no feature
+ if (id >= 16777216) {
+ this.hitOverflow = id - 16777215;
+ id = id % 16777216 + 1;
+ }
+ var hex = "000000" + id.toString(16);
+ var len = hex.length;
+ hex = "#" + hex.substring(len-6, len);
+ return hex;
+ },
+
+ /**
+ * Method: featureIdToRGB
+ * Convert a feature ID string into an RGB array.
+ *
+ * Parameters:
+ * featureId - {String} Feature id
+ *
+ * Returns:
+ * {Array} RGB values.
+ */
+ featureIdToRGB: function(featureId) {
+ var hex = this.featureIdToHex(featureId);
+ return [
+ parseInt(hex.substring(1, 3), 16),
+ parseInt(hex.substring(3, 5), 16),
+ parseInt(hex.substring(5, 7), 16)
+ ];
+ },
+
+ /**
+ * Method: setHitContextStyle
+ * Prepare the hit canvas for drawing by setting various global settings.
+ *
+ * Parameters:
+ * type - {String} one of 'stroke', 'fill', or 'reset'
+ * featureId - {String} The feature id.
+ * symbolizer - {} The symbolizer.
+ */
+ setHitContextStyle: function(type, featureId, symbolizer) {
+ var hex = this.featureIdToHex(featureId);
+ if (type == "fill") {
+ this.hitContext.globalAlpha = 1.0;
+ this.hitContext.fillStyle = hex;
+ } else if (type == "stroke") {
+ this.hitContext.globalAlpha = 1.0;
+ this.hitContext.strokeStyle = hex;
+ // bump up stroke width to deal with antialiasing
+ this.hitContext.lineWidth = symbolizer.strokeWidth + 2;
+ } else {
+ this.hitContext.globalAlpha = 0;
+ this.hitContext.lineWidth = 1;
+ }
+ },
/**
* Method: drawPoint
@@ -210,32 +345,50 @@ OpenLayers.Renderer.Canvas = OpenLayers.Class(OpenLayers.Renderer, {
* Parameters:
* geometry - {}
* style - {Object}
+ * featureId - {String}
*/
- drawPoint: function(geometry, style) {
+ drawPoint: function(geometry, style, featureId) {
if(style.graphic !== false) {
var pt = this.getLocalXY(geometry);
-
- if (style.externalGraphic) {
- this.drawExternalGraphic(pt, style);
- } else {
- if(style.fill !== false) {
- this.setCanvasStyle("fill", style);
- this.canvas.beginPath();
- this.canvas.arc(pt[0], pt[1], style.pointRadius, 0, Math.PI*2, true);
- this.canvas.fill();
- }
-
- if(style.stroke !== false) {
- this.setCanvasStyle("stroke", style);
- this.canvas.beginPath();
- this.canvas.arc(pt[0], pt[1], style.pointRadius, 0, Math.PI*2, true);
- this.canvas.stroke();
- this.setCanvasStyle("reset");
+ var p0 = pt[0];
+ var p1 = pt[1];
+ if (!isNaN(p0) && !isNaN(p1)) {
+ if (style.externalGraphic) {
+ this.drawExternalGraphic(pt, style, featureId);
+ } else {
+ var twoPi = Math.PI*2;
+ var radius = style.pointRadius;
+ if(style.fill !== false) {
+ this.setCanvasStyle("fill", style);
+ this.canvas.beginPath();
+ this.canvas.arc(p0, p1, radius, 0, twoPi, true);
+ this.canvas.fill();
+ if (this.hitDetection) {
+ this.setHitContextStyle("fill", featureId, style);
+ this.hitContext.beginPath();
+ this.hitContext.arc(p0, p1, radius, 0, twoPi, true);
+ this.hitContext.fill();
+ }
+ }
+
+ if(style.stroke !== false) {
+ this.setCanvasStyle("stroke", style);
+ this.canvas.beginPath();
+ this.canvas.arc(p0, p1, radius, 0, twoPi, true);
+ this.canvas.stroke();
+ if (this.hitDetection) {
+ this.setHitContextStyle("stroke", featureId, style);
+ this.hitContext.beginPath();
+ this.hitContext.arc(p0, p1, radius, 0, twoPi, true);
+ this.hitContext.stroke();
+ }
+ this.setCanvasStyle("reset");
+ }
}
}
}
},
-
+
/**
* Method: drawLineString
* This method is only called by the renderer itself.
@@ -243,20 +396,11 @@ OpenLayers.Renderer.Canvas = OpenLayers.Class(OpenLayers.Renderer, {
* Parameters:
* geometry - {}
* style - {Object}
+ * featureId - {String}
*/
- drawLineString: function(geometry, style) {
- if(style.stroke !== false) {
- this.setCanvasStyle("stroke", style);
- this.canvas.beginPath();
- var start = this.getLocalXY(geometry.components[0]);
- this.canvas.moveTo(start[0], start[1]);
- for(var i = 1; i < geometry.components.length; i++) {
- var pt = this.getLocalXY(geometry.components[i]);
- this.canvas.lineTo(pt[0], pt[1]);
- }
- this.canvas.stroke();
- }
- this.setCanvasStyle("reset");
+ drawLineString: function(geometry, style, featureId) {
+ style = OpenLayers.Util.applyDefaults({fill: false}, style);
+ this.drawLinearRing(geometry, style, featureId);
},
/**
@@ -266,33 +410,52 @@ OpenLayers.Renderer.Canvas = OpenLayers.Class(OpenLayers.Renderer, {
* Parameters:
* geometry - {}
* style - {Object}
+ * featureId - {String}
*/
- drawLinearRing: function(geometry, style) {
- if(style.fill !== false) {
+ drawLinearRing: function(geometry, style, featureId) {
+ if (style.fill !== false) {
this.setCanvasStyle("fill", style);
- this.canvas.beginPath();
- var start = this.getLocalXY(geometry.components[0]);
- this.canvas.moveTo(start[0], start[1]);
- for(var i = 1; i < geometry.components.length - 1 ; i++) {
- var pt = this.getLocalXY(geometry.components[i]);
- this.canvas.lineTo(pt[0], pt[1]);
+ this.renderPath(this.canvas, geometry, style, featureId, "fill");
+ if (this.hitDetection) {
+ this.setHitContextStyle("fill", featureId, style);
+ this.renderPath(this.hitContext, geometry, style, featureId, "fill");
}
- this.canvas.fill();
}
-
- if(style.stroke !== false) {
+ if (style.stroke !== false) {
this.setCanvasStyle("stroke", style);
- this.canvas.beginPath();
- var start = this.getLocalXY(geometry.components[0]);
- this.canvas.moveTo(start[0], start[1]);
- for(var i = 1; i < geometry.components.length; i++) {
- var pt = this.getLocalXY(geometry.components[i]);
- this.canvas.lineTo(pt[0], pt[1]);
+ this.renderPath(this.canvas, geometry, style, featureId, "stroke");
+ if (this.hitDetection) {
+ this.setHitContextStyle("stroke", featureId, style);
+ this.renderPath(this.hitContext, geometry, style, featureId, "stroke");
}
- this.canvas.stroke();
}
this.setCanvasStyle("reset");
- },
+ },
+
+ /**
+ * Method: renderPath
+ * Render a path with stroke and optional fill.
+ */
+ renderPath: function(context, geometry, style, featureId, type) {
+ var components = geometry.components;
+ var len = components.length;
+ context.beginPath();
+ var start = this.getLocalXY(components[0]);
+ var x = start[0];
+ var y = start[1];
+ if (!isNaN(x) && !isNaN(y)) {
+ context.moveTo(start[0], start[1]);
+ for (var i=1; i}
* style - {Object}
+ * featureId - {String}
*/
- drawPolygon: function(geometry, style) {
- this.drawLinearRing(geometry.components[0], style);
- for (var i = 1; i < geometry.components.length; i++) {
- this.drawLinearRing(geometry.components[i], {
- fillOpacity: 0,
- strokeWidth: 0,
- strokeOpacity: 0,
- strokeColor: '#000000',
- fillColor: '#000000'}
- ); // inner rings are 'empty'
+ drawPolygon: function(geometry, style, featureId) {
+ var components = geometry.components;
+ var len = components.length;
+ this.drawLinearRing(components[0], style, featureId);
+ // erase inner rings
+ for (var i=1; i}
*
* Returns:
- * {String} A feature id or null.
+ * {