diff --git a/examples/earthquake-clusters.html b/examples/earthquake-clusters.html new file mode 100644 index 0000000000..d3f630e066 --- /dev/null +++ b/examples/earthquake-clusters.html @@ -0,0 +1,75 @@ + + + + + + + + + + + Earthquake Clusters + + + + + + +
+ +
+
+
+
+
+ +
+ +
+

Earthquake Clusters

+

Demonstrates the use of style geometries to render source features of a cluster.

+
+

+ This example parses a KML file and renders the features as clusters on a vector layer. The styling in this example is quite involved. Single earthquake locations (rendered as stars) have a size relative to their magnitude. Clusters have an opacity relative to the number of features in the cluster, and a size that represents the extent of the features that make up the cluster. When clicking or hovering on a cluster, the individual features that make up the cluster will be shown. +

+

To achieve this, we make heavy use of style functions and ol.style.Style#geometry. See the earthquake-clusters.js source to see how this is done.

+
+
KML, vector, style, geometry, cluster
+
+
+ +
+ + + + + + + + diff --git a/examples/earthquake-clusters.js b/examples/earthquake-clusters.js new file mode 100644 index 0000000000..862963d5cc --- /dev/null +++ b/examples/earthquake-clusters.js @@ -0,0 +1,155 @@ +goog.require('ol.Map'); +goog.require('ol.View'); +goog.require('ol.extent'); +goog.require('ol.interaction'); +goog.require('ol.interaction.Select'); +goog.require('ol.layer.Tile'); +goog.require('ol.layer.Vector'); +goog.require('ol.source.Cluster'); +goog.require('ol.source.KML'); +goog.require('ol.source.Stamen'); +goog.require('ol.style.Circle'); +goog.require('ol.style.Fill'); +goog.require('ol.style.RegularShape'); +goog.require('ol.style.Stroke'); +goog.require('ol.style.Style'); +goog.require('ol.style.Text'); + + +var earthquakeFill = new ol.style.Fill({ + color: 'rgba(255, 153, 0, 0.8)' +}); +var earthquakeStroke = new ol.style.Stroke({ + color: 'rgba(255, 204, 0, 0.2)', + width: 1 +}); +var textFill = new ol.style.Fill({ + color: '#fff' +}); +var textStroke = new ol.style.Stroke({ + color: 'rgba(0, 0, 0, 0.6)', + width: 3 +}); +var invisibleFill = new ol.style.Fill({ + color: 'rgba(255, 255, 255, 0.01)' +}); + +function createEarthquakeStyle(feature) { + // 2012_Earthquakes_Mag5.kml stores the magnitude of each earthquake in a + // standards-violating tag in each Placemark. We extract it + // from the Placemark's name instead. + var name = feature.get('name'); + var magnitude = parseFloat(name.substr(2)); + var radius = 5 + 20 * (magnitude - 5); + + return new ol.style.Style({ + geometry: feature.getGeometry(), + image: new ol.style.RegularShape({ + radius1: radius, + radius2: 3, + points: 5, + angle: Math.PI, + fill: earthquakeFill, + stroke: earthquakeStroke + }) + }); +} + +var maxFeatureCount; +function calculateClusterInfo(resolution) { + maxFeatureCount = 0; + var features = vector.getSource().getFeatures(); + var feature, radius; + for (var i = features.length - 1; i >= 0; --i) { + feature = features[i]; + var originalFeatures = feature.get('features'); + var extent = ol.extent.createEmpty(); + for (var j = 0, jj = originalFeatures.length; j < jj; ++j) { + ol.extent.extendCoordinate(extent, + originalFeatures[j].getGeometry().getCoordinates()); + } + maxFeatureCount = Math.max(maxFeatureCount, jj); + radius = 0.25 * (ol.extent.getWidth(extent) + ol.extent.getHeight(extent)) / + resolution; + feature.set('radius', radius); + } +} + +var currentResolution; +function styleFunction(feature, resolution) { + if (resolution != currentResolution) { + calculateClusterInfo(resolution); + currentResolution = resolution; + } + var style; + var size = feature.get('features').length; + if (size > 1) { + style = [new ol.style.Style({ + image: new ol.style.Circle({ + radius: feature.get('radius'), + fill: new ol.style.Fill({ + color: [255, 153, 0, Math.min(0.8, 0.4 + (size / maxFeatureCount))] + }) + }), + text: new ol.style.Text({ + text: size.toString(), + fill: textFill, + stroke: textStroke + }) + })]; + } else { + var originalFeature = feature.get('features')[0]; + style = [createEarthquakeStyle(originalFeature)]; + } + return style; +} + +function selectStyleFunction(feature, resolution) { + var styles = [new ol.style.Style({ + image: new ol.style.Circle({ + radius: feature.get('radius'), + fill: invisibleFill + }) + })]; + var originalFeatures = feature.get('features'); + var originalFeature; + for (var i = originalFeatures.length - 1; i >= 0; --i) { + originalFeature = originalFeatures[i]; + styles.push(createEarthquakeStyle(originalFeature)); + } + return styles; +} + +var vector = new ol.layer.Vector({ + source: new ol.source.Cluster({ + distance: 40, + source: new ol.source.KML({ + extractStyles: false, + projection: 'EPSG:3857', + url: 'data/kml/2012_Earthquakes_Mag5.kml' + }) + }), + style: styleFunction +}); + +var raster = new ol.layer.Tile({ + source: new ol.source.Stamen({ + layer: 'toner' + }) +}); + +var map = new ol.Map({ + layers: [raster, vector], + interactions: ol.interaction.defaults().extend([new ol.interaction.Select({ + condition: function(evt) { + return evt.originalEvent.type == 'mousemove' || + evt.type == 'singleclick'; + }, + style: selectStyleFunction + })]), + target: 'map', + view: new ol.View({ + center: [0, 0], + zoom: 2 + }) +}); diff --git a/externs/olx.js b/externs/olx.js index 4370a7602f..83c1e2f418 100644 --- a/externs/olx.js +++ b/externs/olx.js @@ -6075,7 +6075,8 @@ olx.style.TextOptions.prototype.stroke; /** - * @typedef {{fill: (ol.style.Fill|undefined), + * @typedef {{geometry: (undefined|string|ol.geom.Geometry|ol.style.GeometryFunction), + * fill: (ol.style.Fill|undefined), * image: (ol.style.Image|undefined), * stroke: (ol.style.Stroke|undefined), * text: (ol.style.Text|undefined), @@ -6085,6 +6086,15 @@ olx.style.TextOptions.prototype.stroke; olx.style.StyleOptions; +/** + * Feature property or geometry or function returning a geometry to render for + * this style. + * @type {undefined|string|ol.geom.Geometry|ol.style.GeometryFunction} + * @api + */ +olx.style.StyleOptions.prototype.geometry; + + /** * Fill style. * @type {ol.style.Fill|undefined} diff --git a/src/ol/render/canvas/canvasimmediate.js b/src/ol/render/canvas/canvasimmediate.js index 15bcef6896..efaaac5893 100644 --- a/src/ol/render/canvas/canvasimmediate.js +++ b/src/ol/render/canvas/canvasimmediate.js @@ -477,7 +477,7 @@ ol.render.canvas.Immediate.prototype.drawCircleGeometry = * @api */ ol.render.canvas.Immediate.prototype.drawFeature = function(feature, style) { - var geometry = feature.getGeometry(); + var geometry = style.getGeometryFunction()(feature); if (!goog.isDefAndNotNull(geometry) || !ol.extent.intersects(this.extent_, geometry.getExtent())) { return; diff --git a/src/ol/render/vector.js b/src/ol/render/vector.js index c4481e958d..e02c7ada18 100644 --- a/src/ol/render/vector.js +++ b/src/ol/render/vector.js @@ -123,7 +123,7 @@ ol.renderer.vector.renderFeature = function( */ ol.renderer.vector.renderFeature_ = function( replayGroup, feature, style, squaredTolerance) { - var geometry = feature.getGeometry(); + var geometry = style.getGeometryFunction()(feature); if (!goog.isDefAndNotNull(geometry)) { return; } diff --git a/src/ol/render/webgl/webglimmediate.js b/src/ol/render/webgl/webglimmediate.js index 5095504b7a..4c07223ade 100644 --- a/src/ol/render/webgl/webglimmediate.js +++ b/src/ol/render/webgl/webglimmediate.js @@ -118,7 +118,7 @@ ol.render.webgl.Immediate.prototype.drawCircleGeometry = * @api */ ol.render.webgl.Immediate.prototype.drawFeature = function(feature, style) { - var geometry = feature.getGeometry(); + var geometry = style.getGeometryFunction()(feature); if (!goog.isDefAndNotNull(geometry) || !ol.extent.intersects(this.extent_, geometry.getExtent())) { return; diff --git a/src/ol/style/style.js b/src/ol/style/style.js index ad45b198c4..e010186eac 100644 --- a/src/ol/style/style.js +++ b/src/ol/style/style.js @@ -1,7 +1,9 @@ goog.provide('ol.style.Style'); +goog.provide('ol.style.defaultGeometryFunction'); goog.require('goog.asserts'); goog.require('goog.functions'); +goog.require('ol.geom.Geometry'); goog.require('ol.geom.GeometryType'); goog.require('ol.style.Circle'); goog.require('ol.style.Fill'); @@ -24,6 +26,22 @@ ol.style.Style = function(opt_options) { var options = goog.isDef(opt_options) ? opt_options : {}; + /** + * @private + * @type {string|ol.geom.Geometry|ol.style.GeometryFunction} + */ + this.geometry_ = null; + + /** + * @private + * @type {!ol.style.GeometryFunction} + */ + this.geometryFunction_ = ol.style.defaultGeometryFunction; + + if (goog.isDef(options.geometry)) { + this.setGeometry(options.geometry); + } + /** * @private * @type {ol.style.Fill} @@ -57,6 +75,27 @@ ol.style.Style = function(opt_options) { }; +/** + * @return {string|ol.geom.Geometry|ol.style.GeometryFunction} + * Feature property or geometry or function that returns the geometry that will + * be rendered with this style. + * @api + */ +ol.style.Style.prototype.getGeometry = function() { + return this.geometry_; +}; + + +/** + * @return {!ol.style.GeometryFunction} Function that is called with a feature + * and returns the geometry to render instead of the feature's geometry. + * @api + */ +ol.style.Style.prototype.getGeometryFunction = function() { + return this.geometryFunction_; +}; + + /** * @return {ol.style.Fill} Fill style. * @api @@ -102,6 +141,37 @@ ol.style.Style.prototype.getZIndex = function() { }; +/** + * Set a geometry that is rendered instead of the feature's geometry. + * + * @param {string|ol.geom.Geometry|ol.style.GeometryFunction} geometry + * Feature property or geometry or function returning a geometry to render + * for this style. + * @api + */ +ol.style.Style.prototype.setGeometry = function(geometry) { + if (goog.isFunction(geometry)) { + this.geometryFunction_ = geometry; + } else if (goog.isString(geometry)) { + this.geometryFunction_ = function(feature) { + var result = feature.get(geometry); + if (goog.isDefAndNotNull(result)) { + goog.asserts.assertInstanceof(result, ol.geom.Geometry); + } + return result; + }; + } else if (goog.isNull(geometry)) { + this.geometryFunction_ = ol.style.defaultGeometryFunction; + } else if (goog.isDef(geometry)) { + goog.asserts.assertInstanceof(geometry, ol.geom.Geometry); + this.geometryFunction_ = function() { + return geometry; + }; + } + this.geometry_ = geometry; +}; + + /** * Set the zIndex. * @@ -264,3 +334,24 @@ ol.style.createDefaultEditingStyles = function() { return styles; }; + + +/** + * A function that takes an {@link ol.Feature} as argument and returns an + * {@link ol.geom.Geometry} that will be rendered and styled for the feature. + * + * @typedef {function(ol.Feature): (ol.geom.Geometry|undefined)} + * @api + */ +ol.style.GeometryFunction; + + +/** + * Function that is called with a feature and returns its default geometry. + * @param {ol.Feature} feature Feature to get the geometry for. + * @return {ol.geom.Geometry|undefined} Geometry to render. + */ +ol.style.defaultGeometryFunction = function(feature) { + goog.asserts.assert(!goog.isNull(feature)); + return feature.getGeometry(); +}; diff --git a/test/spec/ol/style.test.js b/test/spec/ol/style.test.js index 8c64cb0497..2a42d4ac02 100644 --- a/test/spec/ol/style.test.js +++ b/test/spec/ol/style.test.js @@ -11,6 +11,53 @@ describe('ol.style.Style', function() { expect(style.getZIndex()).to.be(0.7); }); }); + + describe('#setGeometry', function() { + var style = new ol.style.Style(); + + it('creates a geometry function from a string', function() { + var feature = new ol.Feature(); + feature.set('myGeom', new ol.geom.Point([0, 0])); + style.setGeometry('myGeom'); + expect(style.getGeometryFunction()(feature)) + .to.eql(feature.get('myGeom')); + }); + + it('creates a geometry function from a geometry', function() { + var geom = new ol.geom.Point([0, 0]); + style.setGeometry(geom); + expect(style.getGeometryFunction()()) + .to.eql(geom); + }); + + it('returns the configured geometry function', function() { + var geom = new ol.geom.Point([0, 0]); + style.setGeometry(function() { + return geom; + }); + expect(style.getGeometryFunction()()) + .to.eql(geom); + }); + }); + + describe('#getGeometry', function() { + + it('returns whatever was passed to setGeometry', function() { + var style = new ol.style.Style(); + style.setGeometry('foo'); + expect(style.getGeometry()).to.eql('foo'); + var geom = new ol.geom.Point([1, 2]); + style.setGeometry(geom); + expect(style.getGeometry()).to.eql(geom); + var fn = function() { return geom; }; + style.setGeometry(fn); + expect(style.getGeometry()).to.eql(fn); + style.setGeometry(null); + expect(style.getGeometry()).to.eql(null); + }); + + }); + }); describe('ol.style.createStyleFunction()', function() { @@ -42,4 +89,6 @@ describe('ol.style.createStyleFunction()', function() { }); +goog.require('ol.Feature'); +goog.require('ol.geom.Point'); goog.require('ol.style.Style');