diff --git a/examples/street-labels.html b/examples/street-labels.html new file mode 100644 index 0000000000..bab0d1b061 --- /dev/null +++ b/examples/street-labels.html @@ -0,0 +1,17 @@ +--- +layout: example.html +title: Street Labels +shortdesc: Render street names with a custom render. +docs: > + Example showing the use of a custom renderer to render text along a path. [Labelgun](https://github.com/Geovation/labelgun) is used to avoid label collisions. [label-segment](https://github.com/ahocevar/label-segment) makes sure that labels are placed on suitable street segments. [textpath](https://github.com/ahocevar/textpath) arranges the letters of a label along the geometry. The data is fetched from OSM using the [Overpass API](https://overpass-api.de). +tags: "vector, label, collision detection, labelgun, linelabel, overpass" +resources: + - https://cdn.polyfill.io/v2/polyfill.min.js?features=Set" + - https://unpkg.com/rbush@2.0.1/rbush.min.js + - https://unpkg.com/labelgun@0.1.1/lib/labelgun.min.js + - https://unpkg.com/textpath@1.0.1/dist/textpath.js + - https://unpkg.com/label-segment@1.0.0/dist/label-segment.js +cloak: + As1HiMj1PvLPlqc_gtM7AqZfBL8ZL3VrjaS3zIb22Uvb9WKhuJObROC-qUpa81U5: Your Bing Maps Key from http://www.bingmapsportal.com/ here +--- +
diff --git a/examples/street-labels.js b/examples/street-labels.js new file mode 100644 index 0000000000..464cd2785d --- /dev/null +++ b/examples/street-labels.js @@ -0,0 +1,116 @@ +// NOCOMPILE +/* global labelgun, labelSegment, textPath */ +goog.require('ol.Map'); +goog.require('ol.View'); +goog.require('ol.extent'); +goog.require('ol.format.OSMXML'); +goog.require('ol.layer.Tile'); +goog.require('ol.layer.Vector'); +goog.require('ol.source.BingMaps'); +goog.require('ol.source.Vector'); +goog.require('ol.style.Style'); + +var emptyFn = function() {}; +var labelEngine = new labelgun['default'](emptyFn, emptyFn); + +var context, pixelRatio; // Will be set in the map's postcompose listener +function measureText(text) { + return context.measureText(text).width * pixelRatio; +} + +var extent, letters; // Will be set in the style's renderer function +function collectDrawData(letter, x, y, angle) { + ol.extent.extendCoordinate(extent, [x, y]); + letters.push([x, y, angle, letter]); +} + +var style = new ol.style.Style({ + renderer: function(coords, geometry, feature) { + var text = feature.get('name'); + if (text) { + // Only create label when geometry has a long and straight segment + var path = labelSegment(coords, Math.PI / 8, measureText(text)); + if (path) { + extent = ol.extent.createEmpty(); + letters = []; + textPath(text, path, measureText, collectDrawData); + ol.extent.buffer(extent, 5 * pixelRatio, extent); + var bounds = { + bottomLeft: ol.extent.getBottomLeft(extent), + topRight: ol.extent.getTopRight(extent) + }; + labelEngine.ingestLabel(bounds, feature.getId(), 1, letters, text, false); + } + } + } +}); + +var rasterLayer = new ol.layer.Tile({ + source: new ol.source.BingMaps({ + key: 'As1HiMj1PvLPlqc_gtM7AqZfBL8ZL3VrjaS3zIb22Uvb9WKhuJObROC-qUpa81U5', + imagerySet: 'Aerial' + }) +}); + +var source = new ol.source.Vector(); +// Request streets from OSM, using the Overpass API +fetch('https://overpass-api.de/api/interpreter', { + method: 'POST', + body: '(way["highway"](48.19642,16.32580,48.22050,16.41986));(._;>;);out meta;' +}).then(function(response) { + return response.text(); +}).then(function(responseText) { + var features = new ol.format.OSMXML().readFeatures(responseText, { + featureProjection: 'EPSG:3857' + }); + source.addFeatures(features); +}); + +var vectorLayer = new ol.layer.Vector({ + source: source, + style: function(feature) { + if (feature.getGeometry().getType() == 'LineString') { + return style; + } + } +}); + +var viewExtent = [1817379, 6139595, 1827851, 6143616]; +var map = new ol.Map({ + layers: [rasterLayer, vectorLayer], + target: 'map', + view: new ol.View({ + extent: viewExtent, + center: ol.extent.getCenter(viewExtent), + zoom: 17, + minZoom: 14 + }) +}); + +vectorLayer.on('precompose', function() { + labelEngine.destroy(); +}); +vectorLayer.on('postcompose', function(e) { + context = e.context; + pixelRatio = e.frameState.pixelRatio; + context.save(); + context.font = 'normal 11px "Open Sans", "Arial Unicode MS"'; + context.fillStyle = 'white'; + context.textBaseline = 'middle'; + context.textAlign = 'center'; + var labels = labelEngine.getShown(); + for (var i = 0, ii = labels.length; i < ii; ++i) { + // Render label letter by letter + var letters = labels[i].labelObject; + for (var j = 0, jj = letters.length; j < jj; ++j) { + var labelData = letters[j]; + context.save(); + context.translate(labelData[0], labelData[1]); + context.rotate(labelData[2]); + context.scale(pixelRatio, pixelRatio); + context.fillText(labelData[3], 0, 0); + context.restore(); + } + } + context.restore(); +}); diff --git a/examples/vector-label-decluttering.html b/examples/vector-label-decluttering.html new file mode 100644 index 0000000000..cc45ca1d74 --- /dev/null +++ b/examples/vector-label-decluttering.html @@ -0,0 +1,13 @@ +--- +layout: example.html +title: Vector Label Decluttering +shortdesc: Label decluttering with a custom renderer. +resources: + - https://cdn.polyfill.io/v2/polyfill.min.js?features=Set" + - https://unpkg.com/rbush@2.0.1/rbush.min.js + - https://unpkg.com/labelgun@0.1.1/lib/labelgun.min.js +docs: > + A custom `renderer` function is used instead of an `ol.style.Text` to label the countries of the world. Only texts that are not wider than their country's bounding box are considered and handed over to [Labelgun](https://github.com/Geovation/labelgun) for decluttering. +tags: "vector, renderer, labelgun, label" +--- +
diff --git a/examples/vector-label-decluttering.js b/examples/vector-label-decluttering.js new file mode 100644 index 0000000000..9a50d04dd9 --- /dev/null +++ b/examples/vector-label-decluttering.js @@ -0,0 +1,132 @@ +// NOCOMPILE +/* global labelgun */ +goog.require('ol.Map'); +goog.require('ol.View'); +goog.require('ol.extent'); +goog.require('ol.format.GeoJSON'); +goog.require('ol.geom.Point'); +goog.require('ol.layer.Vector'); +goog.require('ol.source.Vector'); +goog.require('ol.style.Fill'); +goog.require('ol.style.Stroke'); +goog.require('ol.style.Style'); + +// Style for labels +function setStyle(context) { + context.font = '12px Calibri,sans-serif'; + context.fillStyle = '#000'; + context.strokeStyle = '#fff'; + context.lineWidth = 3; + context.textBaseline = 'hanging'; + context.textAlign = 'start'; +} + +// A separate canvas context for measuring label width and height. +var textMeasureContext = document.createElement('CANVAS').getContext('2d'); +setStyle(textMeasureContext); + +// The label height is approximated by the width of the text 'WI'. +var height = textMeasureContext.measureText('WI').width; + +// A cache for reusing label images once they have been created. +var textCache = {}; + +var map = new ol.Map({ + target: 'map', + view: new ol.View({ + center: [0, 0], + zoom: 1 + }) +}); + +var emptyFn = function() {}; +var labelEngine = new labelgun['default'](emptyFn, emptyFn); + +function createLabel(canvas, text, coord) { + var halfWidth = canvas.width / 2; + var halfHeight = canvas.height / 2; + var bounds = { + bottomLeft: [Math.round(coord[0] - halfWidth), Math.round(coord[1] - halfHeight)], + topRight: [Math.round(coord[0] + halfWidth), Math.round(coord[1] + halfHeight)] + }; + labelEngine.ingestLabel(bounds, coord.toString(), 1, canvas, text, false); +} + +// For multi-polygons, we only label the widest polygon. This is done by sorting +// by extent width in descending order, and take the first from the array. +function sortByWidth(a, b) { + return ol.extent.getWidth(b.getExtent()) - ol.extent.getWidth(a.getExtent()); +} + +var resolution; // This is set by the map's precompose listener +var styles = [ + new ol.style.Style({ + fill: new ol.style.Fill({ + color: 'rgba(255, 255, 255, 0.6)' + }), + stroke: new ol.style.Stroke({ + color: '#319FD3', + width: 1 + }) + }), + new ol.style.Style({ + renderer: function(coord, geometry, feature, state) { + var pixelRatio = state.pixelRatio; + var text = feature.get('name'); + var canvas = textCache[text]; + if (!canvas) { + // Draw the label to its own canvas and cache it. + var width = textMeasureContext.measureText(text).width; + canvas = textCache[text] = document.createElement('CANVAS'); + canvas.width = width * pixelRatio; + canvas.height = height * pixelRatio; + var context = canvas.getContext('2d'); + context.scale(pixelRatio, pixelRatio); + setStyle(context); + context.strokeText(text, 0, 0); + context.fillText(text, 0, 0); + } + // The 3rd value of the coordinate is the measure of the extent width + var extentWidth = geometry.getCoordinates()[2] / resolution * pixelRatio; + if (extentWidth > canvas.width) { + // Only consider labels not wider than their country's bounding box + createLabel(canvas, text, coord); + } + }, + // Geometry function to determine label positions + geometry: function(feature) { + var geometry = feature.getGeometry(); + if (geometry.getType() == 'MultiPolygon') { + var geometries = geometry.getPolygons(); + geometry = geometries.sort(sortByWidth)[0]; + } + var coordinates = geometry.getInteriorPoint().getCoordinates(); + var extentWidth = ol.extent.getWidth(geometry.getExtent()); + // We are using the extentWidth as measure value of the geometry + coordinates.push(extentWidth); + return new ol.geom.Point(coordinates, 'XYM'); + } + }) +]; + +var vectorLayer = new ol.layer.Vector({ + source: new ol.source.Vector({ + url: 'data/geojson/countries.geojson', + format: new ol.format.GeoJSON() + }), + style: styles +}); +vectorLayer.on('precompose', function(e) { + resolution = e.frameState.viewState.resolution; + labelEngine.destroy(); +}); +vectorLayer.on('postcompose', function(e) { + var labels = labelEngine.getShown(); + for (var i = 0, ii = labels.length; i < ii; ++i) { + var label = labels[i]; + // Draw label to the map canvas + e.context.drawImage(label.labelObject, label.minX, label.minY); + } +}); + +map.addLayer(vectorLayer); diff --git a/externs/olx.js b/externs/olx.js index e909141fb5..e07514a3ea 100644 --- a/externs/olx.js +++ b/externs/olx.js @@ -4400,6 +4400,48 @@ olx.layer.VectorTileOptions.prototype.visible; olx.render; +/** + * @typedef {{context: CanvasRenderingContext2D, + * pixelRatio: number, + * resolution: number, + * rotation: number}} + */ +olx.render.State; + + +/** + * Canvas context that the layer is being rendered to. + * @type {CanvasRenderingContext2D} + * @api + */ +olx.render.State.prototype.context; + + +/** + * Pixel ratio used by the layer renderer. + * @type {number} + * @api + */ +olx.render.State.prototype.pixelRatio; + + +/** + * Resolution that the render batch was created and optimized for. This is + * not the view's resolution that is being rendered. + * @type {number} + * @api + */ +olx.render.State.prototype.resolution; + + +/** + * Rotation of the rendered layer in radians. + * @type {number} + * @api + */ +olx.render.State.prototype.rotation; + + /** * @typedef {{size: (ol.Size|undefined), * pixelRatio: (number|undefined)}} @@ -7621,6 +7663,7 @@ olx.style.TextOptions.prototype.stroke; * @typedef {{geometry: (undefined|string|ol.geom.Geometry|ol.StyleGeometryFunction), * fill: (ol.style.Fill|undefined), * image: (ol.style.Image|undefined), + * renderer: (ol.StyleRenderFunction|undefined), * stroke: (ol.style.Stroke|undefined), * text: (ol.style.Text|undefined), * zIndex: (number|undefined)}} @@ -7653,6 +7696,16 @@ olx.style.StyleOptions.prototype.fill; olx.style.StyleOptions.prototype.image; +/** + * 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. + * + * @type {ol.StyleRenderFunction|undefined} + */ +olx.style.StyleOptions.prototype.renderer; + + /** * Stroke style. * @type {ol.style.Stroke|undefined} diff --git a/src/ol/geom/simplegeometry.js b/src/ol/geom/simplegeometry.js index ecd5626144..f8821f1192 100644 --- a/src/ol/geom/simplegeometry.js +++ b/src/ol/geom/simplegeometry.js @@ -41,6 +41,12 @@ ol.geom.SimpleGeometry = function() { */ this.flatCoordinates = null; + /** + * @private + * @type {Array.|Array.>|Array.>>} + */ + this.renderCoordinates_ = null; + }; ol.inherits(ol.geom.SimpleGeometry, ol.geom.Geometry); @@ -141,6 +147,18 @@ ol.geom.SimpleGeometry.prototype.getLayout = function() { }; +/** + * @return {Array.|Array.>|Array.>>} + * Render coordinates. + */ +ol.geom.SimpleGeometry.prototype.getRenderCoordinates = function() { + if (!this.renderCoordinates_) { + this.renderCoordinates_ = []; + } + return this.renderCoordinates_; +}; + + /** * @inheritDoc */ diff --git a/src/ol/render/canvas/instruction.js b/src/ol/render/canvas/instruction.js index 8985e59b97..78419e7692 100644 --- a/src/ol/render/canvas/instruction.js +++ b/src/ol/render/canvas/instruction.js @@ -8,13 +8,14 @@ ol.render.canvas.Instruction = { BEGIN_PATH: 1, CIRCLE: 2, CLOSE_PATH: 3, - DRAW_IMAGE: 4, - DRAW_TEXT: 5, - END_GEOMETRY: 6, - FILL: 7, - MOVE_TO_LINE_TO: 8, - SET_FILL_STYLE: 9, - SET_STROKE_STYLE: 10, - SET_TEXT_STYLE: 11, - STROKE: 12 + CUSTOM: 4, + DRAW_IMAGE: 5, + DRAW_TEXT: 6, + END_GEOMETRY: 7, + FILL: 8, + MOVE_TO_LINE_TO: 9, + SET_FILL_STYLE: 10, + SET_STROKE_STYLE: 11, + SET_TEXT_STYLE: 12, + STROKE: 13 }; diff --git a/src/ol/render/canvas/linestringreplay.js b/src/ol/render/canvas/linestringreplay.js index 91e86dce8f..218b18c1ea 100644 --- a/src/ol/render/canvas/linestringreplay.js +++ b/src/ol/render/canvas/linestringreplay.js @@ -37,7 +37,7 @@ ol.render.canvas.LineStringReplay = function(tolerance, maxExtent, resolution, o * currentLineJoin: (string|undefined), * currentLineWidth: (number|undefined), * currentMiterLimit: (number|undefined), - * lastStroke: number, + * lastStroke: (number|undefined), * strokeStyle: (ol.ColorLike|undefined), * lineCap: (string|undefined), * lineDash: Array., @@ -54,7 +54,7 @@ ol.render.canvas.LineStringReplay = function(tolerance, maxExtent, resolution, o currentLineJoin: undefined, currentLineWidth: undefined, currentMiterLimit: undefined, - lastStroke: 0, + lastStroke: undefined, strokeStyle: undefined, lineCap: undefined, lineDash: null, @@ -122,10 +122,11 @@ ol.render.canvas.LineStringReplay.prototype.setStrokeStyle_ = function() { state.currentLineJoin != lineJoin || state.currentLineWidth != lineWidth || state.currentMiterLimit != miterLimit) { - if (state.lastStroke != this.coordinates.length) { + if (state.lastStroke != undefined && state.lastStroke != this.coordinates.length) { this.instructions.push([ol.render.canvas.Instruction.STROKE]); state.lastStroke = this.coordinates.length; } + state.lastStroke = 0; this.instructions.push([ ol.render.canvas.Instruction.SET_STROKE_STYLE, strokeStyle, lineWidth, lineCap, lineJoin, miterLimit, lineDash, lineDashOffset, true, 1 @@ -208,7 +209,7 @@ ol.render.canvas.LineStringReplay.prototype.drawMultiLineString = function(multi */ ol.render.canvas.LineStringReplay.prototype.finish = function() { var state = this.state_; - if (state.lastStroke != this.coordinates.length) { + if (state.lastStroke != undefined && state.lastStroke != this.coordinates.length) { this.instructions.push([ol.render.canvas.Instruction.STROKE]); } this.reverseHitDetectionInstructions(); diff --git a/src/ol/render/canvas/replay.js b/src/ol/render/canvas/replay.js index 9550eda872..309ce70586 100644 --- a/src/ol/render/canvas/replay.js +++ b/src/ol/render/canvas/replay.js @@ -4,6 +4,8 @@ goog.require('ol'); goog.require('ol.array'); goog.require('ol.extent'); goog.require('ol.extent.Relationship'); +goog.require('ol.geom.GeometryType'); +goog.require('ol.geom.flat.inflate'); goog.require('ol.geom.flat.transform'); goog.require('ol.has'); goog.require('ol.obj'); @@ -174,6 +176,75 @@ ol.render.canvas.Replay.prototype.appendFlatCoordinates = function(flatCoordinat }; +/** + * @param {Array.} flatCoordinates Flat coordinates. + * @param {number} offset Offset. + * @param {Array.} ends Ends. + * @param {number} stride Stride. + * @param {Array.} replayEnds Replay ends. + * @return {number} Offset. + */ +ol.render.canvas.Replay.prototype.drawCustomCoordinates_ = function(flatCoordinates, offset, ends, stride, replayEnds) { + for (var i = 0, ii = ends.length; i < ii; ++i) { + var end = ends[i]; + var replayEnd = this.appendFlatCoordinates(flatCoordinates, offset, end, stride, false, false); + replayEnds.push(replayEnd); + offset = end; + } + return offset; +}; + + +/** + * @inheritDoc. + */ +ol.render.canvas.Replay.prototype.drawCustom = function(geometry, feature, renderer) { + this.beginGeometry(geometry, feature); + var type = geometry.getType(); + var stride = geometry.getStride(); + var replayBegin = this.coordinates.length; + var flatCoordinates, replayEnd, replayEnds, replayEndss; + var offset; + if (type == ol.geom.GeometryType.MULTI_POLYGON) { + geometry = /** @type {ol.geom.MultiPolygon} */ (geometry); + flatCoordinates = geometry.getOrientedFlatCoordinates(); + replayEndss = []; + var endss = geometry.getEndss(); + offset = 0; + for (var i = 0, ii = endss.length; i < ii; ++i) { + var myEnds = []; + offset = this.drawCustomCoordinates_(flatCoordinates, offset, endss[i], stride, myEnds); + replayEndss.push(myEnds); + } + this.instructions.push([ol.render.canvas.Instruction.CUSTOM, + replayBegin, replayEndss, geometry, renderer, ol.geom.flat.inflate.coordinatesss]); + } else if (type == ol.geom.GeometryType.POLYGON || type == ol.geom.GeometryType.MULTI_LINE_STRING) { + replayEnds = []; + flatCoordinates = (type == ol.geom.GeometryType.POLYGON) ? + /** @type {ol.geom.Polygon} */ (geometry).getOrientedFlatCoordinates() : + geometry.getFlatCoordinates(); + offset = this.drawCustomCoordinates_(flatCoordinates, 0, + /** @type {ol.geom.Polygon|ol.geom.MultiLineString} */ (geometry).getEnds(), + stride, replayEnds); + this.instructions.push([ol.render.canvas.Instruction.CUSTOM, + replayBegin, replayEnds, geometry, renderer, ol.geom.flat.inflate.coordinatess]); + } else if (type == ol.geom.GeometryType.LINE_STRING || type == ol.geom.GeometryType.MULTI_POINT) { + flatCoordinates = geometry.getFlatCoordinates(); + replayEnd = this.appendFlatCoordinates( + flatCoordinates, 0, flatCoordinates.length, stride, false, false); + this.instructions.push([ol.render.canvas.Instruction.CUSTOM, + replayBegin, replayEnd, geometry, renderer, ol.geom.flat.inflate.coordinates]); + } else if (type == ol.geom.GeometryType.POINT) { + flatCoordinates = geometry.getFlatCoordinates(); + this.coordinates.push(flatCoordinates[0], flatCoordinates[1]); + replayEnd = this.coordinates.length; + this.instructions.push([ol.render.canvas.Instruction.CUSTOM, + replayBegin, replayEnd, geometry, renderer]); + } + this.endGeometry(geometry, feature); +}; + + /** * @protected * @param {ol.geom.Geometry|ol.render.Feature} geometry Geometry. @@ -249,6 +320,17 @@ ol.render.canvas.Replay.prototype.replay_ = function( var prevX, prevY, roundX, roundY; var pendingFill = 0; var pendingStroke = 0; + + /** + * @type {olx.render.State} + */ + var state = { + context: context, + pixelRatio: pixelRatio, + resolution: this.resolution, + rotation: viewRotation + }; + // When the batch size gets too big, performance decreases. 200 is a good // balance between batch size and number of fill/stroke instructions. var batchSize = @@ -303,6 +385,21 @@ ol.render.canvas.Replay.prototype.replay_ = function( context.closePath(); ++i; break; + case ol.render.canvas.Instruction.CUSTOM: + d = /** @type {number} */ (instruction[1]); + dd = instruction[2]; + var geometry = /** @type {ol.geom.SimpleGeometry} */ (instruction[3]); + var renderer = instruction[4]; + var coords; + if (instruction.length == 6) { + var fn = instruction[5]; + coords = fn(pixelCoordinates, d, dd, 2, geometry.getRenderCoordinates()); + } else { + coords = pixelCoordinates.slice(d, dd); + } + renderer(coords, geometry, feature, state); + ++i; + break; case ol.render.canvas.Instruction.DRAW_IMAGE: d = /** @type {number} */ (instruction[1]); dd = /** @type {number} */ (instruction[2]); diff --git a/src/ol/render/canvas/replaygroup.js b/src/ol/render/canvas/replaygroup.js index 356b982799..a4ee79a49a 100644 --- a/src/ol/render/canvas/replaygroup.js +++ b/src/ol/render/canvas/replaygroup.js @@ -7,6 +7,7 @@ goog.require('ol.extent'); goog.require('ol.geom.flat.transform'); goog.require('ol.obj'); goog.require('ol.render.ReplayGroup'); +goog.require('ol.render.canvas.Replay'); goog.require('ol.render.canvas.ImageReplay'); goog.require('ol.render.canvas.LineStringReplay'); goog.require('ol.render.canvas.PolygonReplay'); @@ -161,6 +162,24 @@ ol.render.canvas.ReplayGroup.getCircleArray_ = function(radius) { return arr; }; + +/** + * @param {Array.} replays Replays. + * @return {boolean} Has replays of the provided types. + */ +ol.render.canvas.ReplayGroup.prototype.hasReplays = function(replays) { + for (var zIndex in this.replaysByZIndex_) { + var candidates = this.replaysByZIndex_[zIndex]; + for (var i = 0, ii = replays.length; i < ii; ++i) { + if (replays[i] in candidates) { + return true; + } + } + } + return false; +}; + + /** * FIXME empty description for jsdoc */ @@ -387,6 +406,7 @@ ol.render.canvas.ReplayGroup.prototype.replayHitDetection_ = function( */ ol.render.canvas.ReplayGroup.BATCH_CONSTRUCTORS_ = { 'Circle': ol.render.canvas.PolygonReplay, + 'Default': ol.render.canvas.Replay, 'Image': ol.render.canvas.ImageReplay, 'LineString': ol.render.canvas.LineStringReplay, 'Polygon': ol.render.canvas.PolygonReplay, diff --git a/src/ol/render/feature.js b/src/ol/render/feature.js index 702dbeb2c2..8bbec70671 100644 --- a/src/ol/render/feature.js +++ b/src/ol/render/feature.js @@ -43,6 +43,12 @@ ol.render.Feature = function(type, flatCoordinates, ends, properties, id) { */ this.flatCoordinates_ = flatCoordinates; + /** + * @private + * @type {Array.|Array.>|Array.>>} + */ + this.renderCoordinates_ = null; + /** * @private * @type {Array.|Array.>} @@ -111,6 +117,18 @@ ol.render.Feature.prototype.getOrientedFlatCoordinates = function() { }; +/** + * @return {Array.|Array.>|Array.>>} + * Render coordinates. + */ +ol.render.Feature.prototype.getRenderCoordinates = function() { + if (!this.renderCoordinates_) { + this.renderCoordinates_ = []; + } + return this.renderCoordinates_; +}; + + /** * @return {Array.} Flat coordinates. */ diff --git a/src/ol/render/replay.js b/src/ol/render/replay.js index a0a8e0dc7e..3737e5ef03 100644 --- a/src/ol/render/replay.js +++ b/src/ol/render/replay.js @@ -12,5 +12,6 @@ ol.render.replay.ORDER = [ ol.render.ReplayType.CIRCLE, ol.render.ReplayType.LINE_STRING, ol.render.ReplayType.IMAGE, - ol.render.ReplayType.TEXT + ol.render.ReplayType.TEXT, + ol.render.ReplayType.DEFAULT ]; diff --git a/src/ol/render/replaytype.js b/src/ol/render/replaytype.js index 5423cee151..066bcef098 100644 --- a/src/ol/render/replaytype.js +++ b/src/ol/render/replaytype.js @@ -6,6 +6,7 @@ goog.provide('ol.render.ReplayType'); */ ol.render.ReplayType = { CIRCLE: 'Circle', + DEFAULT: 'Default', IMAGE: 'Image', LINE_STRING: 'LineString', POLYGON: 'Polygon', diff --git a/src/ol/render/vectorcontext.js b/src/ol/render/vectorcontext.js index a8974f1cc7..4428177138 100644 --- a/src/ol/render/vectorcontext.js +++ b/src/ol/render/vectorcontext.js @@ -13,6 +13,16 @@ ol.render.VectorContext = function() { }; +/** + * Render a geometry with a custom renderer. + * + * @param {ol.geom.SimpleGeometry} geometry Geometry. + * @param {ol.Feature|ol.render.Feature} feature Feature. + * @param {Function} renderer Renderer. + */ +ol.render.VectorContext.prototype.drawCustom = function(geometry, feature, renderer) {}; + + /** * Render a geometry. * diff --git a/src/ol/renderer/canvas/vectortilelayer.js b/src/ol/renderer/canvas/vectortilelayer.js index e0482fcec5..ee6c12ac60 100644 --- a/src/ol/renderer/canvas/vectortilelayer.js +++ b/src/ol/renderer/canvas/vectortilelayer.js @@ -61,7 +61,8 @@ ol.inherits(ol.renderer.canvas.VectorTileLayer, ol.renderer.canvas.TileLayer); * @type {!Object.>} */ ol.renderer.canvas.VectorTileLayer.IMAGE_REPLAYS = { - 'image': ol.render.replay.ORDER, + 'image': [ol.render.ReplayType.POLYGON, ol.render.ReplayType.CIRCLE, + ol.render.ReplayType.LINE_STRING, ol.render.ReplayType.IMAGE, ol.render.ReplayType.TEXT], 'hybrid': [ol.render.ReplayType.POLYGON, ol.render.ReplayType.LINE_STRING] }; @@ -71,7 +72,8 @@ ol.renderer.canvas.VectorTileLayer.IMAGE_REPLAYS = { * @type {!Object.>} */ ol.renderer.canvas.VectorTileLayer.VECTOR_REPLAYS = { - 'hybrid': [ol.render.ReplayType.IMAGE, ol.render.ReplayType.TEXT], + 'image': [ol.render.ReplayType.DEFAULT], + 'hybrid': [ol.render.ReplayType.IMAGE, ol.render.ReplayType.TEXT, ol.render.ReplayType.DEFAULT], 'vector': ol.render.replay.ORDER }; @@ -332,61 +334,62 @@ ol.renderer.canvas.VectorTileLayer.prototype.postCompose = function(context, fra var source = layer.getSource(); var renderMode = layer.getRenderMode(); var replays = ol.renderer.canvas.VectorTileLayer.VECTOR_REPLAYS[renderMode]; - if (replays) { - var pixelRatio = frameState.pixelRatio; - var rotation = frameState.viewState.rotation; - var size = frameState.size; - var offsetX = Math.round(pixelRatio * size[0] / 2); - var offsetY = Math.round(pixelRatio * size[1] / 2); - var tiles = this.renderedTiles; - var tilePixelRatio = layer.getSource().getTilePixelRatio(); - var sourceTileGrid = source.getTileGrid(); - var tileGrid = source.getTileGridForProjection(frameState.viewState.projection); - var clips = []; - var zs = []; - for (var i = tiles.length - 1; i >= 0; --i) { - var tile = /** @type {ol.VectorImageTile} */ (tiles[i]); - if (tile.getState() == ol.TileState.ABORT) { + var pixelRatio = frameState.pixelRatio; + var rotation = frameState.viewState.rotation; + var size = frameState.size; + var offsetX = Math.round(pixelRatio * size[0] / 2); + var offsetY = Math.round(pixelRatio * size[1] / 2); + var tiles = this.renderedTiles; + var tilePixelRatio = layer.getSource().getTilePixelRatio(); + var sourceTileGrid = source.getTileGrid(); + var tileGrid = source.getTileGridForProjection(frameState.viewState.projection); + var clips = []; + var zs = []; + for (var i = tiles.length - 1; i >= 0; --i) { + var tile = /** @type {ol.VectorImageTile} */ (tiles[i]); + if (tile.getState() == ol.TileState.ABORT) { + continue; + } + var tileCoord = tile.tileCoord; + var worldOffset = tileGrid.getTileCoordExtent(tileCoord)[0] - + tileGrid.getTileCoordExtent(tile.wrappedTileCoord)[0]; + for (var t = 0, tt = tile.tileKeys.length; t < tt; ++t) { + var sourceTile = tile.getTile(tile.tileKeys[t]); + var replayGroup = sourceTile.getReplayGroup(layer, tileCoord.toString()); + if (renderMode != ol.layer.VectorTileRenderType.VECTOR && !replayGroup.hasReplays(replays)) { continue; } - var tileCoord = tile.tileCoord; - var worldOffset = tileGrid.getTileCoordExtent(tileCoord)[0] - - tileGrid.getTileCoordExtent(tile.wrappedTileCoord)[0]; - for (var t = 0, tt = tile.tileKeys.length; t < tt; ++t) { - var sourceTile = tile.getTile(tile.tileKeys[t]); - var currentZ = sourceTile.tileCoord[0]; - var sourceResolution = sourceTileGrid.getResolution(currentZ); - var transform = this.getReplayTransform_(sourceTile, frameState); - ol.transform.translate(transform, worldOffset * tilePixelRatio / sourceResolution, 0); - var replayGroup = sourceTile.getReplayGroup(layer, tileCoord.toString()); - var currentClip = replayGroup.getClipCoords(transform); - context.save(); - context.globalAlpha = layerState.opacity; - ol.render.canvas.rotateAtOffset(context, -rotation, offsetX, offsetY); - // Create a clip mask for regions in this low resolution tile that are - // already filled by a higher resolution tile - for (var j = 0, jj = clips.length; j < jj; ++j) { - var clip = clips[j]; - if (currentZ < zs[j]) { - context.beginPath(); - // counter-clockwise (outer ring) for current tile - context.moveTo(currentClip[0], currentClip[1]); - context.lineTo(currentClip[2], currentClip[3]); - context.lineTo(currentClip[4], currentClip[5]); - context.lineTo(currentClip[6], currentClip[7]); - // clockwise (inner ring) for higher resolution tile - context.moveTo(clip[6], clip[7]); - context.lineTo(clip[4], clip[5]); - context.lineTo(clip[2], clip[3]); - context.lineTo(clip[0], clip[1]); - context.clip(); - } + var currentZ = sourceTile.tileCoord[0]; + var sourceResolution = sourceTileGrid.getResolution(currentZ); + var transform = this.getReplayTransform_(sourceTile, frameState); + ol.transform.translate(transform, worldOffset * tilePixelRatio / sourceResolution, 0); + var currentClip = replayGroup.getClipCoords(transform); + context.save(); + context.globalAlpha = layerState.opacity; + ol.render.canvas.rotateAtOffset(context, -rotation, offsetX, offsetY); + // Create a clip mask for regions in this low resolution tile that are + // already filled by a higher resolution tile + for (var j = 0, jj = clips.length; j < jj; ++j) { + var clip = clips[j]; + if (currentZ < zs[j]) { + context.beginPath(); + // counter-clockwise (outer ring) for current tile + context.moveTo(currentClip[0], currentClip[1]); + context.lineTo(currentClip[2], currentClip[3]); + context.lineTo(currentClip[4], currentClip[5]); + context.lineTo(currentClip[6], currentClip[7]); + // clockwise (inner ring) for higher resolution tile + context.moveTo(clip[6], clip[7]); + context.lineTo(clip[4], clip[5]); + context.lineTo(clip[2], clip[3]); + context.lineTo(clip[0], clip[1]); + context.clip(); } - replayGroup.replay(context, pixelRatio, transform, rotation, {}, replays); - context.restore(); - clips.push(currentClip); - zs.push(currentZ); } + replayGroup.replay(context, pixelRatio, transform, rotation, {}, replays); + context.restore(); + clips.push(currentClip); + zs.push(currentZ); } } ol.renderer.canvas.TileLayer.prototype.postCompose.apply(this, arguments); @@ -466,7 +469,7 @@ ol.renderer.canvas.VectorTileLayer.prototype.renderTileImage_ = function( ol.transform.translate(transform, -tileExtent[0], -tileExtent[3]); } var replayGroup = sourceTile.getReplayGroup(layer, tile.tileCoord.toString()); - replayGroup.replay(context, pixelRatio, transform, 0, {}, replays); + replayGroup.replay(context, pixelRatio, transform, 0, {}, replays, true); } } }; diff --git a/src/ol/renderer/vector.js b/src/ol/renderer/vector.js index 14f522c659..b0efa64a47 100644 --- a/src/ol/renderer/vector.js +++ b/src/ol/renderer/vector.js @@ -2,6 +2,7 @@ goog.provide('ol.renderer.vector'); goog.require('ol'); goog.require('ol.ImageState'); +goog.require('ol.geom.GeometryType'); goog.require('ol.render.ReplayType'); @@ -111,9 +112,34 @@ ol.renderer.vector.renderFeature_ = function( return; } var simplifiedGeometry = geometry.getSimplifiedGeometry(squaredTolerance); - var geometryRenderer = - ol.renderer.vector.GEOMETRY_RENDERERS_[simplifiedGeometry.getType()]; - geometryRenderer(replayGroup, simplifiedGeometry, style, feature); + var renderer = style.getRenderer(); + if (renderer) { + ol.renderer.vector.renderGeometry_(replayGroup, simplifiedGeometry, style, feature); + } else { + var geometryRenderer = + ol.renderer.vector.GEOMETRY_RENDERERS_[simplifiedGeometry.getType()]; + geometryRenderer(replayGroup, simplifiedGeometry, style, feature); + } +}; + + +/** + * @param {ol.render.ReplayGroup} replayGroup Replay group. + * @param {ol.geom.Geometry} geometry Geometry. + * @param {ol.style.Style} style Style. + * @param {ol.Feature|ol.render.Feature} feature Feature. + * @private + */ +ol.renderer.vector.renderGeometry_ = function(replayGroup, geometry, style, feature) { + if (geometry.getType() == ol.geom.GeometryType.GEOMETRY_COLLECTION) { + var geometries = /** @type {ol.geom.GeometryCollection} */ (geometry).getGeometries(); + for (var i = 0, ii = geometries.length; i < ii; ++i) { + ol.renderer.vector.renderGeometry_(replayGroup, geometries[i], style, feature); + } + return; + } + var replay = replayGroup.getReplay(style.getZIndex(), ol.render.ReplayType.DEFAULT); + replay.drawCustom(/** @type {ol.geom.SimpleGeometry} */ (geometry), feature, style.getRenderer()); }; diff --git a/src/ol/style/style.js b/src/ol/style/style.js index b3f076ec5e..09dd93407f 100644 --- a/src/ol/style/style.js +++ b/src/ol/style/style.js @@ -50,6 +50,12 @@ ol.style.Style = function(opt_options) { */ this.image_ = options.image !== undefined ? options.image : null; + /** + * @private + * @type {ol.StyleRenderFunction|null} + */ + this.renderer_ = options.renderer !== undefined ? options.renderer : null; + /** * @private * @type {ol.style.Stroke} @@ -92,6 +98,28 @@ ol.style.Style.prototype.clone = function() { }; +/** + * Get the custom renderer function that was configured with + * {@link #setRenderer} or the `renderer` constructor option. + * @return {ol.StyleRenderFunction|null} Custom renderer function. + * @api + */ +ol.style.Style.prototype.getRenderer = function() { + return this.renderer_; +}; + + +/** + * Sets a custom renderer function for this style. When set, `fill`, `stroke` + * and `image` options of the style will be ignored. + * @param {ol.StyleRenderFunction|null} renderer Custom renderer function. + * @api + */ +ol.style.Style.prototype.setRenderer = function(renderer) { + this.renderer_ = renderer; +}; + + /** * Get the geometry to be rendered. * @return {string|ol.geom.Geometry|ol.StyleGeometryFunction} diff --git a/src/ol/typedefs.js b/src/ol/typedefs.js index d9300c5912..ec24160a3e 100644 --- a/src/ol/typedefs.js +++ b/src/ol/typedefs.js @@ -626,6 +626,19 @@ ol.StyleFunction; ol.StyleGeometryFunction; +/** + * Custom renderer function. Takes 4 arguments: + * + * 1. The pixel coordinates of the geometry in GeoJSON notation. + * 2. The original {@link ol.geom.SimpleGeometry}. + * 3. The underlying {@link ol.Feature} or {@link ol.render.Feature}. + * 4. The {@link olx.render.State} of the layer renderer. + * + * @typedef {function(Array,ol.geom.SimpleGeometry,(ol.Feature|ol.render.Feature),olx.render.State)} + */ +ol.StyleRenderFunction; + + /** * @typedef {{opacity: number, * rotateWithView: boolean, diff --git a/tasks/build-examples.js b/tasks/build-examples.js index 2275e0bf85..47054ea603 100644 --- a/tasks/build-examples.js +++ b/tasks/build-examples.js @@ -10,7 +10,7 @@ var markupRegEx = /([^\/^\.]*)\.html$/; var cleanupJSRegEx = /.*(\/\/ NOCOMPILE|goog\.require\(.*\);)[\r\n]*/g; var requiresRegEx = /.*goog\.require\('(ol\.\S*)'\);/g; var isCssRegEx = /\.css$/; -var isJsRegEx = /\.js$/; +var isJsRegEx = /\.js(\?.*)?$/; var srcDir = path.join(__dirname, '..', 'examples'); var destDir = path.join(__dirname, '..', 'build', 'examples'); diff --git a/test/spec/ol/renderer/canvas/replay.test.js b/test/spec/ol/renderer/canvas/replay.test.js index 306367dd69..6398f5b55b 100644 --- a/test/spec/ol/renderer/canvas/replay.test.js +++ b/test/spec/ol/renderer/canvas/replay.test.js @@ -205,6 +205,53 @@ describe('ol.render.canvas.ReplayGroup', function() { expect(style2.getStroke().getLineDashOffset()).to.be(2); expect(lineDashOffset).to.be(4); }); + + it('calls the renderer function configured for the style', function() { + var spy = sinon.spy(); + var style = new ol.style.Style({ + renderer: spy + }); + var point = new ol.Feature(new ol.geom.Point([45, 90])); + var multipoint = new ol.Feature(new ol.geom.MultiPoint( + [[45, 90], [90, 45]])); + var linestring = new ol.Feature(new ol.geom.LineString( + [[45, 90], [45, 45], [90, 45]])); + var multilinestring = new ol.Feature(new ol.geom.MultiLineString( + [linestring.getGeometry().getCoordinates(), linestring.getGeometry().getCoordinates()])); + var polygon = feature1; + var multipolygon = new ol.Feature(new ol.geom.MultiPolygon( + [polygon.getGeometry().getCoordinates(), polygon.getGeometry().getCoordinates()])); + var geometrycollection = new ol.Feature(new ol.geom.GeometryCollection( + [point.getGeometry(), linestring.getGeometry(), polygon.getGeometry()])); + replay = new ol.render.canvas.ReplayGroup(1, [-180, -90, 180, 90], 1, true); + ol.renderer.vector.renderFeature(replay, point, style, 1); + ol.renderer.vector.renderFeature(replay, multipoint, style, 1); + ol.renderer.vector.renderFeature(replay, linestring, style, 1); + ol.renderer.vector.renderFeature(replay, multilinestring, style, 1); + ol.renderer.vector.renderFeature(replay, polygon, style, 1); + ol.renderer.vector.renderFeature(replay, multipolygon, style, 1); + ol.renderer.vector.renderFeature(replay, geometrycollection, style, 1); + ol.transform.scale(transform, 0.1, 0.1); + replay.replay(context, 1, transform, 0, {}); + expect(spy.callCount).to.be(9); + expect(spy.firstCall.args.length).to.be(4); + expect(spy.firstCall.args[1]).to.be(point.getGeometry()); + expect(spy.firstCall.args[2]).to.be(point); + expect(spy.firstCall.args[3].context).to.be(context); + expect(spy.firstCall.args[3].pixelRatio).to.be(1); + expect(spy.firstCall.args[3].rotation).to.be(0); + expect(spy.firstCall.args[3].resolution).to.be(1); + expect(spy.getCall(0).args[0]).to.eql([4.5, 9]); + expect(spy.getCall(1).args[0][0]).to.eql([4.5, 9]); + expect(spy.getCall(2).args[0][0]).to.eql([4.5, 9]); + expect(spy.getCall(3).args[0][0][0]).to.eql([4.5, 9]); + expect(spy.getCall(4).args[0][0][0]).to.eql([-9, -4.5]); + expect(spy.getCall(5).args[0][0][0][0]).to.eql([-9, -4.5]); + expect(spy.getCall(6).args[2]).to.be(geometrycollection); + expect(spy.getCall(6).args[1].getCoordinates()).to.eql([45, 90]); + expect(spy.getCall(7).args[1].getCoordinates()[0]).to.eql([45, 90]); + expect(spy.getCall(8).args[1].getCoordinates()[0][0]).to.eql([-90, -45]); + }); }); }); diff --git a/test/spec/ol/renderer/canvas/vectortilelayer.test.js b/test/spec/ol/renderer/canvas/vectortilelayer.test.js index 0b9664f200..69fbfa7b45 100644 --- a/test/spec/ol/renderer/canvas/vectortilelayer.test.js +++ b/test/spec/ol/renderer/canvas/vectortilelayer.test.js @@ -121,6 +121,18 @@ describe('ol.renderer.canvas.VectorTileLayer', function() { spy2.restore(); }); + it('renders replays with custom renderers as direct replays', function() { + layer.renderMode_ = 'image'; + layer.setStyle(new ol.style.Style({ + renderer: function() {} + })); + var spy = sinon.spy(ol.renderer.canvas.VectorTileLayer.prototype, + 'getReplayTransform_'); + map.renderSync(); + expect(spy.callCount).to.be(1); + spy.restore(); + }); + it('gives precedence to feature styles over layer styles', function() { var spy = sinon.spy(map.getRenderer().getLayerRenderer(layer), 'renderFeature');