diff --git a/examples/graticule.js b/examples/graticule.js index 468938ce61..1cb67c310a 100644 --- a/examples/graticule.js +++ b/examples/graticule.js @@ -27,6 +27,8 @@ var graticule = new ol.Graticule({ color: 'rgba(255,120,0,0.9)', width: 2, lineDash: [0.5, 4] - }) + }), + showLabels: true }); + graticule.setMap(map); diff --git a/externs/olx.js b/externs/olx.js index 01445f7d27..6f3870360d 100644 --- a/externs/olx.js +++ b/externs/olx.js @@ -112,7 +112,14 @@ olx.LogoOptions.prototype.src; * @typedef {{map: (ol.Map|undefined), * maxLines: (number|undefined), * strokeStyle: (ol.style.Stroke|undefined), - * targetSize: (number|undefined)}} + * targetSize: (number|undefined), + * showLabels: (boolean|undefined), + * lonLabelFormatter: (undefined|function(number):string), + * latLabelFormatter: (undefined|function(number):string), + * lonLabelPosition: (number|undefined), + * latLabelPosition: (number|undefined), + * lonLabelStyle: (ol.style.Text|undefined), + * latLabelStyle: (ol.style.Text|undefined)}} */ olx.GraticuleOptions; @@ -157,6 +164,106 @@ olx.GraticuleOptions.prototype.strokeStyle; olx.GraticuleOptions.prototype.targetSize; +/** + * Render a label with the respective latitude/longitude for each graticule + * line. Default is false. + * + * @type {boolean|undefined} + * @api + */ +olx.GraticuleOptions.prototype.showLabels; + + +/** + * Label formatter for longitudes. This function is called with the longitude as + * argument, and should return a formatted string representing the longitude. + * By default, labels are formatted as degrees, minutes, seconds and hemisphere. + * + * @type {undefined|function(number):string} + * @api + */ +olx.GraticuleOptions.prototype.lonLabelFormatter; + + +/** + * Label formatter for latitudes. This function is called with the latitude as + * argument, and should return a formatted string representing the latitude. + * By default, labels are formatted as degrees, minutes, seconds and hemisphere. + * + * @type {undefined|function(number):string} + * @api + */ +olx.GraticuleOptions.prototype.latLabelFormatter; + + +/** + * Longitude label position in fractions (0..1) of view extent. 0 means at the + * bottom of the viewport, 1 means at the top. Default is 0. + * @type {number|undefined} + * @api + */ +olx.GraticuleOptions.prototype.lonLabelPosition; + + +/** + * Latitude label position in fractions (0..1) of view extent. 0 means at the + * left of the viewport, 1 means at the right. Default is 1. + * @type {number|undefined} + * @api + */ +olx.GraticuleOptions.prototype.latLabelPosition; + + +/** + * Longitude label text style. The default is + * ```js + * new ol.style.Text({ + * font: '12px Calibri,sans-serif', + * textBaseline: 'bottom', + * fill: new ol.style.Fill({ + * color: 'rgba(0,0,0,1)' + * }), + * stroke: new ol.style.Stroke({ + * color: 'rgba(255,255,255,1)', + * width: 3 + * }) + * }); + * ``` + * Note that the default's `textBaseline` configuration will not work well for + * `lonLabelPosition` configurations that position labels close to the top of + * the viewport. + * + * @type {ol.style.Text|undefined} + * @api + */ +olx.GraticuleOptions.prototype.lonLabelStyle; + + +/** + * Latitude label text style. The default is + * ```js + * new ol.style.Text({ + * font: '12px Calibri,sans-serif', + * textAlign: 'end', + * fill: new ol.style.Fill({ + * color: 'rgba(0,0,0,1)' + * }), + * stroke: new ol.style.Stroke({ + * color: 'rgba(255,255,255,1)', + * width: 3 + * }) + * }); + * ``` + * Note that the default's `textAlign` configuration will not work well for + * `latLabelPosition` configurations that position labels close to the left of + * the viewport. + * + * @type {ol.style.Text|undefined} + * @api + */ +olx.GraticuleOptions.prototype.latLabelStyle; + + /** * Object literal with config options for interactions. * @typedef {{handleEvent: function(ol.MapBrowserEvent):boolean}} diff --git a/src/ol/graticule.js b/src/ol/graticule.js index e38f1ca8e3..c2fe312cb7 100644 --- a/src/ol/graticule.js +++ b/src/ol/graticule.js @@ -1,13 +1,17 @@ goog.provide('ol.Graticule'); +goog.require('ol.coordinate'); goog.require('ol.extent'); goog.require('ol.geom.GeometryLayout'); goog.require('ol.geom.LineString'); +goog.require('ol.geom.Point'); goog.require('ol.geom.flat.geodesic'); goog.require('ol.math'); goog.require('ol.proj'); goog.require('ol.render.EventType'); +goog.require('ol.style.Fill'); goog.require('ol.style.Stroke'); +goog.require('ol.style.Text'); /** @@ -104,31 +108,116 @@ ol.Graticule = function(opt_options) { */ this.parallels_ = []; - /** - * @type {ol.style.Stroke} - * @private - */ + /** + * @type {ol.style.Stroke} + * @private + */ this.strokeStyle_ = options.strokeStyle !== undefined ? options.strokeStyle : ol.Graticule.DEFAULT_STROKE_STYLE_; - /** - * @type {ol.TransformFunction|undefined} - * @private - */ + /** + * @type {ol.TransformFunction|undefined} + * @private + */ this.fromLonLatTransform_ = undefined; - /** - * @type {ol.TransformFunction|undefined} - * @private - */ + /** + * @type {ol.TransformFunction|undefined} + * @private + */ this.toLonLatTransform_ = undefined; - /** - * @type {ol.Coordinate} - * @private - */ + /** + * @type {ol.Coordinate} + * @private + */ this.projectionCenterLonLat_ = null; + /** + * @type {Array.} + * @private + */ + this.meridiansLabels_ = null; + + /** + * @type {Array.} + * @private + */ + this.parallelsLabels_ = null; + + if (options.showLabels == true) { + var degreesToString = ol.coordinate.degreesToStringHDMS; + + /** + * @type {null|function(number):string} + * @private + */ + this.lonLabelFormatter_ = options.lonLabelFormatter == undefined ? + degreesToString.bind(this, 'EW') : options.lonLabelFormatter; + + /** + * @type {function(number):string} + * @private + */ + this.latLabelFormatter_ = options.latLabelFormatter == undefined ? + degreesToString.bind(this, 'NS') : options.latLabelFormatter; + + /** + * Longitude label position in fractions (0..1) of view extent. 0 means + * bottom, 1 means top. + * @type {number} + * @private + */ + this.lonLabelPosition_ = options.lonLabelPosition == undefined ? 0 : + options.lonLabelPosition; + + /** + * Latitude Label position in fractions (0..1) of view extent. 0 means left, 1 + * means right. + * @type {number} + * @private + */ + this.latLabelPosition_ = options.latLabelPosition == undefined ? 1 : + options.latLabelPosition; + + /** + * @type {ol.style.Text} + * @private + */ + this.lonLabelStyle_ = options.lonLabelStyle !== undefined ? options.lonLabelStyle : + new ol.style.Text({ + font: '12px Calibri,sans-serif', + textBaseline: 'bottom', + fill: new ol.style.Fill({ + color: 'rgba(0,0,0,1)' + }), + stroke: new ol.style.Stroke({ + color: 'rgba(255,255,255,1)', + width: 3 + }) + }); + + /** + * @type {ol.style.Text} + * @private + */ + this.latLabelStyle_ = options.latLabelStyle !== undefined ? options.latLabelStyle : + new ol.style.Text({ + font: '12px Calibri,sans-serif', + textAlign: 'end', + fill: new ol.style.Fill({ + color: 'rgba(0,0,0,1)' + }), + stroke: new ol.style.Stroke({ + color: 'rgba(255,255,255,1)', + width: 3 + }) + }); + + this.meridiansLabels_ = []; + this.parallelsLabels_ = []; + } + this.setMap(options.map !== undefined ? options.map : null); }; @@ -166,11 +255,39 @@ ol.Graticule.prototype.addMeridian_ = function(lon, minLat, maxLat, squaredToler var lineString = this.getMeridian_(lon, minLat, maxLat, squaredTolerance, index); if (ol.extent.intersects(lineString.getExtent(), extent)) { + if (this.meridiansLabels_) { + var textPoint = this.getMeridianPoint_(lineString, extent, index); + this.meridiansLabels_[index] = { + geom: textPoint, + text: this.lonLabelFormatter_(lon) + }; + } this.meridians_[index++] = lineString; } return index; }; +/** + * @param {ol.geom.LineString} lineString Meridian + * @param {ol.Extent} extent Extent. + * @param {number} index Index. + * @return {ol.geom.Point} Meridian point. + * @private + */ +ol.Graticule.prototype.getMeridianPoint_ = function(lineString, extent, index) { + var flatCoordinates = lineString.getFlatCoordinates(); + var clampedBottom = Math.max(extent[1], flatCoordinates[1]); + var clampedTop = Math.min(extent[3], flatCoordinates[flatCoordinates.length - 1]); + var lat = ol.math.clamp( + extent[1] + Math.abs(extent[1] - extent[3]) * this.lonLabelPosition_, + clampedBottom, clampedTop); + var coordinate = [flatCoordinates[0], lat]; + var point = this.meridiansLabels_[index] !== undefined ? + this.meridiansLabels_[index].geom : new ol.geom.Point(null); + point.setCoordinates(coordinate); + return point; +}; + /** * @param {number} lat Latitude. @@ -186,12 +303,41 @@ ol.Graticule.prototype.addParallel_ = function(lat, minLon, maxLon, squaredToler var lineString = this.getParallel_(lat, minLon, maxLon, squaredTolerance, index); if (ol.extent.intersects(lineString.getExtent(), extent)) { + if (this.parallelsLabels_) { + var textPoint = this.getParallelPoint_(lineString, extent, index); + this.parallelsLabels_[index] = { + geom: textPoint, + text: this.latLabelFormatter_(lat) + }; + } this.parallels_[index++] = lineString; } return index; }; +/** + * @param {ol.geom.LineString} lineString Parallels. + * @param {ol.Extent} extent Extent. + * @param {number} index Index. + * @return {ol.geom.Point} Parallel point. + * @private + */ +ol.Graticule.prototype.getParallelPoint_ = function(lineString, extent, index) { + var flatCoordinates = lineString.getFlatCoordinates(); + var clampedLeft = Math.max(extent[0], flatCoordinates[0]); + var clampedRight = Math.min(extent[2], flatCoordinates[flatCoordinates.length - 2]); + var lon = ol.math.clamp( + extent[0] + Math.abs(extent[0] - extent[2]) * this.latLabelPosition_, + clampedLeft, clampedRight); + var coordinate = [lon, flatCoordinates[1]]; + var point = this.parallelsLabels_[index] !== undefined ? + this.parallelsLabels_[index].geom : new ol.geom.Point(null); + point.setCoordinates(coordinate); + return point; +}; + + /** * @param {ol.Extent} extent Extent. * @param {ol.Coordinate} center Center. @@ -204,6 +350,12 @@ ol.Graticule.prototype.createGraticule_ = function(extent, center, resolution, s var interval = this.getInterval_(resolution); if (interval == -1) { this.meridians_.length = this.parallels_.length = 0; + if (this.meridiansLabels_) { + this.meridiansLabels_.length = 0; + } + if (this.parallelsLabels_) { + this.parallelsLabels_.length = 0; + } return; } @@ -249,6 +401,9 @@ ol.Graticule.prototype.createGraticule_ = function(extent, center, resolution, s } this.meridians_.length = idx; + if (this.meridiansLabels_) { + this.meridiansLabels_.length = idx; + } // Create parallels @@ -272,6 +427,9 @@ ol.Graticule.prototype.createGraticule_ = function(extent, center, resolution, s } this.parallels_.length = idx; + if (this.parallelsLabels_) { + this.parallelsLabels_.length = idx; + } }; @@ -426,11 +584,28 @@ ol.Graticule.prototype.handlePostCompose_ = function(e) { var i, l, line; for (i = 0, l = this.meridians_.length; i < l; ++i) { line = this.meridians_[i]; - vectorContext.drawLineString(line, null); + vectorContext.drawGeometry(line); } for (i = 0, l = this.parallels_.length; i < l; ++i) { line = this.parallels_[i]; - vectorContext.drawLineString(line, null); + vectorContext.drawGeometry(line); + } + var labelData; + if (this.meridiansLabels_) { + for (i = 0, l = this.meridiansLabels_.length; i < l; ++i) { + labelData = this.meridiansLabels_[i]; + this.lonLabelStyle_.setText(labelData.text); + vectorContext.setTextStyle(this.lonLabelStyle_); + vectorContext.drawGeometry(labelData.geom); + } + } + if (this.parallelsLabels_) { + for (i = 0, l = this.parallelsLabels_.length; i < l; ++i) { + labelData = this.parallelsLabels_[i]; + this.latLabelStyle_.setText(labelData.text); + vectorContext.setTextStyle(this.latLabelStyle_); + vectorContext.drawGeometry(labelData.geom); + } } }; diff --git a/src/ol/typedefs.js b/src/ol/typedefs.js index 4370156ff0..80da8e58b9 100644 --- a/src/ol/typedefs.js +++ b/src/ol/typedefs.js @@ -253,6 +253,15 @@ ol.FeatureStyleFunction; ol.FeatureUrlFunction; +/** + * @typedef {{ + * geom: ol.geom.Point, + * text: string + * }} + */ +ol.GraticuleLabelDataType; + + /** * A function that is called to trigger asynchronous canvas drawing. It is * called with a "done" callback that should be called when drawing is done. diff --git a/test/spec/ol/graticule.test.js b/test/spec/ol/graticule.test.js index 3611c3c899..dc628c61d0 100644 --- a/test/spec/ol/graticule.test.js +++ b/test/spec/ol/graticule.test.js @@ -9,14 +9,15 @@ goog.require('ol.style.Stroke'); describe('ol.Graticule', function() { var graticule; - beforeEach(function() { + function createGraticule() { graticule = new ol.Graticule({ map: new ol.Map({}) }); - }); + } describe('#createGraticule', function() { - it('creates the graticule', function() { + it('creates a graticule without labels', function() { + createGraticule(); var extent = [-25614353.926475704, -7827151.696402049, 25614353.926475704, 7827151.696402049]; var projection = ol.proj.get('EPSG:3857'); @@ -26,9 +27,32 @@ describe('ol.Graticule', function() { graticule.createGraticule_(extent, [0, 0], resolution, squaredTolerance); expect(graticule.getMeridians().length).to.be(13); expect(graticule.getParallels().length).to.be(3); + expect(graticule.meridiansLabels_).to.be(null); + expect(graticule.parallelsLabels_).to.be(null); + }); + + it('creates a graticule with labels', function() { + graticule = new ol.Graticule({ + map: new ol.Map({}), + showLabels: true + }); + var extent = [-25614353.926475704, -7827151.696402049, + 25614353.926475704, 7827151.696402049]; + var projection = ol.proj.get('EPSG:3857'); + var resolution = 39135.75848201024; + var squaredTolerance = resolution * resolution / 4.0; + graticule.updateProjectionInfo_(projection); + graticule.createGraticule_(extent, [0, 0], resolution, squaredTolerance); + expect(graticule.meridiansLabels_.length).to.be(13); + expect(graticule.meridiansLabels_[0].text).to.be('0° 00′ 00″'); + expect(graticule.meridiansLabels_[0].geom.getCoordinates()[0]).to.roughlyEqual(0, 1e-9); + expect(graticule.parallelsLabels_.length).to.be(3); + expect(graticule.parallelsLabels_[0].text).to.be('0° 00′ 00″'); + expect(graticule.parallelsLabels_[0].geom.getCoordinates()[1]).to.roughlyEqual(0, 1e-9); }); it('has a default stroke style', function() { + createGraticule(); var actualStyle = graticule.strokeStyle_; expect(actualStyle).not.to.be(undefined); @@ -36,6 +60,7 @@ describe('ol.Graticule', function() { }); it('can be configured with a stroke style', function() { + createGraticule(); var customStrokeStyle = new ol.style.Stroke({ color: 'rebeccapurple' }); @@ -49,6 +74,38 @@ describe('ol.Graticule', function() { expect(actualStyle).to.be(customStrokeStyle); }); + it('can be configured with label options', function() { + var latLabelStyle = new ol.style.Text(); + var lonLabelStyle = new ol.style.Text(); + graticule = new ol.Graticule({ + map: new ol.Map({}), + showLabels: true, + lonLabelFormatter: function(lon) { + return 'lon: ' + lon.toString(); + }, + latLabelFormatter: function(lat) { + return 'lat: ' + lat.toString(); + }, + lonLabelPosition: 0.9, + latLabelPosition: 0.1, + lonLabelStyle: lonLabelStyle, + latLabelStyle: latLabelStyle + }); + var extent = [-25614353.926475704, -7827151.696402049, + 25614353.926475704, 7827151.696402049]; + var projection = ol.proj.get('EPSG:3857'); + var resolution = 39135.75848201024; + var squaredTolerance = resolution * resolution / 4.0; + graticule.updateProjectionInfo_(projection); + graticule.createGraticule_(extent, [0, 0], resolution, squaredTolerance); + expect(graticule.meridiansLabels_[0].text).to.be('lon: 0'); + expect(graticule.parallelsLabels_[0].text).to.be('lat: 0'); + expect(graticule.lonLabelStyle_).to.eql(lonLabelStyle); + expect(graticule.latLabelStyle_).to.eql(latLabelStyle); + expect(graticule.lonLabelPosition_).to.be(0.9); + expect(graticule.latLabelPosition_).to.be(0.1); + }); + }); });