diff --git a/examples_src/vector-esri-edit.html b/examples_src/vector-esri-edit.html new file mode 100644 index 0000000000..48bfe4e673 --- /dev/null +++ b/examples_src/vector-esri-edit.html @@ -0,0 +1,20 @@ +--- +template: example.html +title: esri ArcGIS REST Feature Service example with editing +shortdesc: Example of using an ArcGIS REST Feature Service in an editing application. +docs: > + This example loads features from ArcGIS REST Feature Service and allows to add new features or update existing features. +tags: "vector, esri, ArcGIS, REST, Feature, Service, bbox, loading, server, edit, updateFeature, addFeature" +--- +
+
+
+
+ + +
+
+
diff --git a/examples_src/vector-esri-edit.js b/examples_src/vector-esri-edit.js new file mode 100644 index 0000000000..234b8d056d --- /dev/null +++ b/examples_src/vector-esri-edit.js @@ -0,0 +1,151 @@ +goog.require('ol.Attribution'); +goog.require('ol.Map'); +goog.require('ol.View'); +goog.require('ol.format.EsriJSON'); +goog.require('ol.interaction'); +goog.require('ol.interaction.Draw'); +goog.require('ol.interaction.Modify'); +goog.require('ol.interaction.Select'); +goog.require('ol.layer.Tile'); +goog.require('ol.layer.Vector'); +goog.require('ol.loadingstrategy'); +goog.require('ol.proj'); +goog.require('ol.source.Vector'); +goog.require('ol.source.XYZ'); +goog.require('ol.tilegrid.XYZ'); + + +var serviceUrl = 'http://services.arcgis.com/rOo16HdIMeOBI4Mb/arcgis/rest/' + + 'services/PDX_Pedestrian_Districts/FeatureServer/'; +var layer = '0'; + +var esrijsonFormat = new ol.format.EsriJSON(); + +var vectorSource = new ol.source.Vector({ + loader: function(extent, resolution, projection) { + var url = serviceUrl + layer + '/query/?f=json&' + + 'returnGeometry=true&spatialRel=esriSpatialRelIntersects&geometry=' + + encodeURIComponent('{"xmin":' + extent[0] + ',"ymin":' + + extent[1] + ',"xmax":' + extent[2] + ',"ymax":' + extent[3] + + ',"spatialReference":{"wkid":102100}}') + + '&geometryType=esriGeometryEnvelope&inSR=102100&outFields=*' + + '&outSR=102100'; + $.ajax({url: url, dataType: 'jsonp', success: function(response) { + // dataProjection will be read from document + var features = esrijsonFormat.readFeatures(response, { + featureProjection: projection + }); + if (features.length > 0) { + vectorSource.addFeatures(features); + } + }}); + }, + strategy: ol.loadingstrategy.tile(new ol.tilegrid.XYZ({ + tileSize: 512 + })) +}); + +var vector = new ol.layer.Vector({ + source: vectorSource +}); + +var draw = new ol.interaction.Draw({ + source: vectorSource, + type: /** @type {ol.geom.GeometryType} */ ('Polygon') +}); + +var select = new ol.interaction.Select(); +select.setActive(false); +var selected = select.getFeatures(); + +var modify = new ol.interaction.Modify({ + features: selected +}); +modify.setActive(false); + +var typeSelect = document.getElementById('type'); + + +/** + * Let user change the interaction type. + * @param {Event} e Change event. + */ +typeSelect.onchange = function(e) { + draw.setActive(typeSelect.value === 'DRAW'); + select.setActive(typeSelect.value === 'MODIFY'); + modify.setActive(typeSelect.value === 'MODIFY'); +}; + +var dirty = {}; + +selected.on('add', function(evt) { + var feature = evt.element; + feature.on('change', function(evt) { + dirty[evt.target.getId()] = true; + }); +}); + +selected.on('remove', function(evt) { + var feature = evt.element; + var fid = feature.getId(); + if (dirty[fid] === true) { + var payload = '[' + esrijsonFormat.writeFeature(feature, { + featureProjection: select.getMap().getView().getProjection() + }) + ']'; + var url = serviceUrl + layer + '/updateFeatures'; + $.post(url, { f: 'json', features: payload }).done(function(data) { + var result = JSON.parse(data); + if (result.updateResults && result.updateResults.length > 0) { + if (result.updateResults[0].success !== true) { + var error = result.updateResults[0].error; + alert(error.description + ' (' + error.code + ')'); + } else { + delete dirty[fid]; + } + } + }); + } +}); + +draw.on('drawend', function(evt) { + var feature = evt.feature; + var payload = '[' + esrijsonFormat.writeFeature(feature, { + featureProjection: evt.target.getMap().getView().getProjection() + }) + ']'; + var url = serviceUrl + layer + '/addFeatures'; + $.post(url, { f: 'json', features: payload }).done(function(data) { + var result = JSON.parse(data); + if (result.addResults && result.addResults.length > 0) { + if (result.addResults[0].success === true) { + feature.setId(result.addResults[0]['objectId']); + vectorSource.clear(); + } else { + var error = result.addResults[0].error; + alert(error.description + ' (' + error.code + ')'); + } + } + }); +}); + +var attribution = new ol.Attribution({ + html: 'Tiles © ArcGIS' +}); + +var raster = new ol.layer.Tile({ + source: new ol.source.XYZ({ + attributions: [attribution], + url: 'http://server.arcgisonline.com/ArcGIS/rest/services/' + + 'World_Topo_Map/MapServer/tile/{z}/{y}/{x}' + }) +}); + +var map = new ol.Map({ + interactions: ol.interaction.defaults().extend([draw, select, modify]), + layers: [raster, vector], + target: document.getElementById('map'), + view: new ol.View({ + center: ol.proj.transform([-122.619, 45.512], 'EPSG:4326', 'EPSG:3857'), + zoom: 12 + }) +}); diff --git a/src/ol/format/esrijsonformat.js b/src/ol/format/esrijsonformat.js index 8230457362..3c43a334a6 100644 --- a/src/ol/format/esrijsonformat.js +++ b/src/ol/format/esrijsonformat.js @@ -111,7 +111,7 @@ ol.format.EsriJSON.convertRings_ = function(rings, hasZ) { } } while (holes.length) { - var hole = holes.pop(); + var hole = holes.shift(); var matched = false; for (i = outerRings.length - 1; i >= 0; i--) { var outerRing = outerRings[i][0]; @@ -221,6 +221,121 @@ ol.format.EsriJSON.readPolygonGeometry_ = function(object) { }; +/** + * @param {ol.geom.Geometry} geometry Geometry. + * @param {olx.format.WriteOptions=} opt_options Write options. + * @private + * @return {EsriJSONGeometry} EsriJSON geometry. + */ +ol.format.EsriJSON.writePointGeometry_ = function(geometry, opt_options) { + goog.asserts.assertInstanceof(geometry, ol.geom.Point, + 'geometry should be an ol.geom.Point'); + var coordinates = geometry.getCoordinates(); + if (geometry.getLayout() === ol.geom.GeometryLayout.XYZ) { + return /** @type {EsriJSONPoint} */ ({ + 'x': coordinates[0], + 'y': coordinates[1], + 'z': coordinates[2] + }); + } else { + return /** @type {EsriJSONPoint} */ ({ + 'x': coordinates[0], + 'y': coordinates[1] + }); + } +}; + + +/** + * @param {ol.geom.Geometry} geometry Geometry. + * @param {olx.format.WriteOptions=} opt_options Write options. + * @private + * @return {EsriJSONPolyline} EsriJSON geometry. + */ +ol.format.EsriJSON.writeLineStringGeometry_ = function(geometry, opt_options) { + goog.asserts.assertInstanceof(geometry, ol.geom.LineString, + 'geometry should be an ol.geom.LineString'); + return /** @type {EsriJSONPolyline} */ ({ + 'hasZ': (geometry.getLayout() === ol.geom.GeometryLayout.XYZ), + 'paths': [geometry.getCoordinates()] + }); +}; + + +/** + * @param {ol.geom.Geometry} geometry Geometry. + * @param {olx.format.WriteOptions=} opt_options Write options. + * @private + * @return {EsriJSONPolygon} EsriJSON geometry. + */ +ol.format.EsriJSON.writePolygonGeometry_ = function(geometry, opt_options) { + goog.asserts.assertInstanceof(geometry, ol.geom.Polygon, + 'geometry should be an ol.geom.Polygon'); + // Esri geometries use the left-hand rule + return /** @type {EsriJSONPolygon} */ ({ + 'hasZ': (geometry.getLayout() === ol.geom.GeometryLayout.XYZ), + 'rings': geometry.getCoordinates(false) + }); +}; + + +/** + * @param {ol.geom.Geometry} geometry Geometry. + * @param {olx.format.WriteOptions=} opt_options Write options. + * @private + * @return {EsriJSONPolyline} EsriJSON geometry. + */ +ol.format.EsriJSON.writeMultiLineStringGeometry_ = + function(geometry, opt_options) { + goog.asserts.assertInstanceof(geometry, ol.geom.MultiLineString, + 'geometry should be an ol.geom.MultiLineString'); + return /** @type {EsriJSONPolyline} */ ({ + 'hasZ': (geometry.getLayout() === ol.geom.GeometryLayout.XYZ), + 'paths': geometry.getCoordinates() + }); +}; + + +/** + * @param {ol.geom.Geometry} geometry Geometry. + * @param {olx.format.WriteOptions=} opt_options Write options. + * @private + * @return {EsriJSONMultipoint} EsriJSON geometry. + */ +ol.format.EsriJSON.writeMultiPointGeometry_ = function(geometry, opt_options) { + goog.asserts.assertInstanceof(geometry, ol.geom.MultiPoint, + 'geometry should be an ol.geom.MultiPoint'); + return /** @type {EsriJSONMultipoint} */ ({ + 'hasZ': (geometry.getLayout() === ol.geom.GeometryLayout.XYZ), + 'points': geometry.getCoordinates() + }); +}; + + +/** + * @param {ol.geom.Geometry} geometry Geometry. + * @param {olx.format.WriteOptions=} opt_options Write options. + * @private + * @return {EsriJSONPolygon} EsriJSON geometry. + */ +ol.format.EsriJSON.writeMultiPolygonGeometry_ = function(geometry, + opt_options) { + goog.asserts.assertInstanceof(geometry, ol.geom.MultiPolygon, + 'geometry should be an ol.geom.MultiPolygon'); + var coordinates = geometry.getCoordinates(false); + var output = []; + for (var i = 0; i < coordinates.length; i++) { + for (var x = coordinates[i].length - 1; x >= 0; x--) { + output.push(coordinates[i][x]); + } + } + return /** @type {EsriJSONPolygon} */ ({ + 'hasZ': (geometry.getLayout() === ol.geom.GeometryLayout.XYZ), + 'rings': output + }); +}; + + /** * @const * @private @@ -236,6 +351,21 @@ ol.format.EsriJSON.GEOMETRY_READERS_ = { }; +/** + * @const + * @private + * @type {Object.} + */ +ol.format.EsriJSON.GEOMETRY_WRITERS_ = { + 'Point': ol.format.EsriJSON.writePointGeometry_, + 'LineString': ol.format.EsriJSON.writeLineStringGeometry_, + 'Polygon': ol.format.EsriJSON.writePolygonGeometry_, + 'MultiPoint': ol.format.EsriJSON.writeMultiPointGeometry_, + 'MultiLineString': ol.format.EsriJSON.writeMultiLineStringGeometry_, + 'MultiPolygon': ol.format.EsriJSON.writeMultiPolygonGeometry_ +}; + + /** * Read a feature from a EsriJSON Feature source. Only works for Feature, * use `readFeatures` to read FeatureCollection source. @@ -244,7 +374,7 @@ ol.format.EsriJSON.GEOMETRY_READERS_ = { * @param {ArrayBuffer|Document|Node|Object|string} source Source. * @param {olx.format.ReadOptions=} opt_options Read options. * @return {ol.Feature} Feature. - * @api stable + * @api */ ol.format.EsriJSON.prototype.readFeature; @@ -257,7 +387,7 @@ ol.format.EsriJSON.prototype.readFeature; * @param {ArrayBuffer|Document|Node|Object|string} source Source. * @param {olx.format.ReadOptions=} opt_options Read options. * @return {Array.} Features. - * @api stable + * @api */ ol.format.EsriJSON.prototype.readFeatures; @@ -327,7 +457,7 @@ ol.format.EsriJSON.prototype.readFeaturesFromObject = function( * @param {ArrayBuffer|Document|Node|Object|string} source Source. * @param {olx.format.ReadOptions=} opt_options Read options. * @return {ol.geom.Geometry} Geometry. - * @api stable + * @api */ ol.format.EsriJSON.prototype.readGeometry; @@ -348,7 +478,7 @@ ol.format.EsriJSON.prototype.readGeometryFromObject = function( * @function * @param {ArrayBuffer|Document|Node|Object|string} source Source. * @return {ol.proj.Projection} Projection. - * @api stable + * @api */ ol.format.EsriJSON.prototype.readProjection; @@ -366,3 +496,126 @@ ol.format.EsriJSON.prototype.readProjectionFromObject = function(object) { return null; } }; + + +/** + * @param {ol.geom.Geometry} geometry Geometry. + * @param {olx.format.WriteOptions=} opt_options Write options. + * @private + * @return {EsriJSONGeometry} EsriJSON geometry. + */ +ol.format.EsriJSON.writeGeometry_ = function(geometry, opt_options) { + var geometryWriter = ol.format.EsriJSON.GEOMETRY_WRITERS_[geometry.getType()]; + goog.asserts.assert(goog.isDef(geometryWriter), + 'geometryWriter should be defined'); + return geometryWriter(/** @type {ol.geom.Geometry} */ ( + ol.format.Feature.transformWithOptions(geometry, true, opt_options)), + opt_options); +}; + + +/** + * Encode a geometry as a EsriJSON string. + * + * @function + * @param {ol.geom.Geometry} geometry Geometry. + * @param {olx.format.WriteOptions=} opt_options Write options. + * @return {string} EsriJSON. + * @api + */ +ol.format.EsriJSON.prototype.writeGeometry; + + +/** + * Encode a geometry as a EsriJSON object. + * + * @param {ol.geom.Geometry} geometry Geometry. + * @param {olx.format.WriteOptions=} opt_options Write options. + * @return {EsriJSONGeometry} Object. + * @api + */ +ol.format.EsriJSON.prototype.writeGeometryObject = function(geometry, + opt_options) { + return ol.format.EsriJSON.writeGeometry_(geometry, + this.adaptOptions(opt_options)); +}; + + +/** + * Encode a feature as a EsriJSON Feature string. + * + * @function + * @param {ol.Feature} feature Feature. + * @param {olx.format.WriteOptions=} opt_options Write options. + * @return {string} EsriJSON. + * @api + */ +ol.format.EsriJSON.prototype.writeFeature; + + +/** + * Encode a feature as a esriJSON Feature object. + * + * @param {ol.Feature} feature Feature. + * @param {olx.format.WriteOptions=} opt_options Write options. + * @return {Object} Object. + * @api + */ +ol.format.EsriJSON.prototype.writeFeatureObject = function( + feature, opt_options) { + opt_options = this.adaptOptions(opt_options); + var object = {}; + var geometry = feature.getGeometry(); + if (goog.isDefAndNotNull(geometry)) { + object['geometry'] = + ol.format.EsriJSON.writeGeometry_(geometry, opt_options); + } + var properties = feature.getProperties(); + goog.object.remove(properties, feature.getGeometryName()); + if (!goog.object.isEmpty(properties)) { + object['attributes'] = properties; + } else { + object['attributes'] = {}; + } + if (goog.isDef(opt_options) && goog.isDef(opt_options.featureProjection)) { + object['spatialReference'] = /** @type {EsriJSONCRS} */({ + wkid: ol.proj.get( + opt_options.featureProjection).getCode().split(':').pop() + }); + } + return object; +}; + + +/** + * Encode an array of features as EsriJSON. + * + * @function + * @param {Array.} features Features. + * @param {olx.format.WriteOptions=} opt_options Write options. + * @return {string} EsriJSON. + * @api + */ +ol.format.EsriJSON.prototype.writeFeatures; + + +/** + * Encode an array of features as a EsriJSON object. + * + * @param {Array.} features Features. + * @param {olx.format.WriteOptions=} opt_options Write options. + * @return {Object} EsriJSON Object. + * @api + */ +ol.format.EsriJSON.prototype.writeFeaturesObject = + function(features, opt_options) { + opt_options = this.adaptOptions(opt_options); + var objects = []; + var i, ii; + for (i = 0, ii = features.length; i < ii; ++i) { + objects.push(this.writeFeatureObject(features[i], opt_options)); + } + return /** @type {EsriJSONFeatureCollection} */ ({ + 'features': objects + }); +}; diff --git a/test/spec/ol/format/esrijsonformat.test.js b/test/spec/ol/format/esrijsonformat.test.js index d9595a0923..3008877221 100644 --- a/test/spec/ol/format/esrijsonformat.test.js +++ b/test/spec/ol/format/esrijsonformat.test.js @@ -495,6 +495,189 @@ describe('ol.format.EsriJSON', function() { }); + describe('#writeGeometry', function() { + + it('encodes point', function() { + var point = new ol.geom.Point([10, 20]); + var esrijson = format.writeGeometry(point); + expect(point.getCoordinates()).to.eql( + format.readGeometry(esrijson).getCoordinates()); + }); + + it('encodes XYZ point', function() { + var point = new ol.geom.Point([10, 20, 0], ol.geom.GeometryLayout.XYZ); + var esrijson = format.writeGeometry(point); + expect(point.getCoordinates()).to.eql( + format.readGeometry(esrijson).getCoordinates()); + }); + + it('encodes linestring', function() { + var linestring = new ol.geom.LineString([[10, 20], [30, 40]]); + var esrijson = format.writeGeometry(linestring); + expect(linestring.getCoordinates()).to.eql( + format.readGeometry(esrijson).getCoordinates()); + }); + + it('encodes XYZ linestring', function() { + var linestring = new ol.geom.LineString([[10, 20, 1534], [30, 40, 1420]], + ol.geom.GeometryLayout.XYZ); + var esrijson = format.writeGeometry(linestring); + expect(linestring.getCoordinates()).to.eql( + format.readGeometry(esrijson).getCoordinates()); + }); + + it('encodes polygon', function() { + var outer = [[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]], + inner1 = [[1, 1], [2, 1], [2, 2], [1, 2], [1, 1]], + inner2 = [[8, 8], [9, 8], [9, 9], [8, 9], [8, 8]]; + var polygon = new ol.geom.Polygon([outer, inner1, inner2]); + var esrijson = format.writeGeometry(polygon); + expect(polygon.getCoordinates(false)).to.eql( + format.readGeometry(esrijson).getCoordinates()); + }); + + it('encodes XYZ polygon', function() { + var outer = [[0, 0, 5], [0, 10, 5], [10, 10, 5], [10, 0, 5], [0, 0, 5]], + inner1 = [[1, 1, 3], [2, 1, 3], [2, 2, 3], [1, 2, 3], [1, 1, 3]], + inner2 = [[8, 8, 2], [9, 8, 2], [9, 9, 2], [8, 9, 2], [8, 8, 2]]; + var polygon = new ol.geom.Polygon([outer, inner1, inner2], + ol.geom.GeometryLayout.XYZ); + var esrijson = format.writeGeometry(polygon); + expect(polygon.getCoordinates(false)).to.eql( + format.readGeometry(esrijson).getCoordinates()); + }); + + it('encodes multipoint', function() { + var multipoint = new ol.geom.MultiPoint([[102.0, 0.0] , [103.0, 1.0]]); + var esrijson = format.writeGeometry(multipoint); + expect(multipoint.getCoordinates()).to.eql( + format.readGeometry(esrijson).getCoordinates()); + }); + + it('encodes XYZ multipoint', function() { + var multipoint = new ol.geom.MultiPoint([[102.0, 0.0, 3], + [103.0, 1.0, 4]], ol.geom.GeometryLayout.XYZ); + var esrijson = format.writeGeometry(multipoint); + expect(multipoint.getCoordinates()).to.eql( + format.readGeometry(esrijson).getCoordinates()); + }); + + it('encodes multilinestring', function() { + var multilinestring = new ol.geom.MultiLineString([ + [[102.0, 0.0], [103.0, 1.0], [104.0, 0.0], [105.0, 1.0]], + [[105.0, 3.0], [106.0, 4.0], [107.0, 3.0], [108.0, 4.0]] + ]); + var esrijson = format.writeGeometry(multilinestring); + expect(multilinestring.getCoordinates()).to.eql( + format.readGeometry(esrijson).getCoordinates()); + }); + + it('encodes XYZ multilinestring', function() { + var multilinestring = new ol.geom.MultiLineString([ + [[102.0, 0.0, 1], [103.0, 1.0, 2], [104.0, 0.0, 3], [105.0, 1.0, 4]], + [[105.0, 3.0, 1], [106.0, 4.0, 2], [107.0, 3.0, 3], [108.0, 4.0, 4]] + ], ol.geom.GeometryLayout.XYZ); + var esrijson = format.writeGeometry(multilinestring); + expect(multilinestring.getCoordinates()).to.eql( + format.readGeometry(esrijson).getCoordinates()); + }); + + it('encodes multipolygon', function() { + var multipolygon = new ol.geom.MultiPolygon([ + [[[0, 1], [1, 4], [4, 3], [3, 0]], [[2, 2], [3, 2], [3, 3], [2, 3]]], + [[[10, 1], [11, 5], [14, 3], [13, 0]]] + ]); + var esrijson = format.writeGeometry(multipolygon); + expect(multipolygon.getCoordinates()).to.eql( + format.readGeometry(esrijson).getCoordinates()); + }); + + it('encodes XYZ multipolygon', function() { + var multipolygon = new ol.geom.MultiPolygon([ + [[[0, 1, 0], [1, 4, 0], [4, 3, 0], [3, 0, 0]], [[2, 2, 0], [3, 2, 0], + [3, 3, 0], [2, 3, 0]]], + [[[10, 1, 0], [11, 5, 0], [14, 3, 0], [13, 0, 0]]] + ], ol.geom.GeometryLayout.XYZ); + var esrijson = format.writeGeometry(multipolygon); + expect(multipolygon.getCoordinates()).to.eql( + format.readGeometry(esrijson).getCoordinates()); + }); + + it('transforms and encodes a point', function() { + var point = new ol.geom.Point([2, 3]); + var esrijson = format.writeGeometry(point, { + dataProjection: 'EPSG:4326', + featureProjection: 'EPSG:3857' + }); + var newPoint = format.readGeometry(esrijson, { + dataProjection: 'EPSG:4326', + featureProjection: 'EPSG:3857' + }); + expect(point.getCoordinates()[0]).to.roughlyEqual( + newPoint.getCoordinates()[0], 1e-8); + expect( + Math.abs(point.getCoordinates()[1] - newPoint.getCoordinates()[1])) + .to.be.lessThan(0.0000001); + }); + + }); + + describe('#writeFeatures', function() { + + it('encodes feature collection', function() { + var str = JSON.stringify(data), + array = format.readFeatures(str); + var esrijson = format.writeFeaturesObject(array); + var result = format.readFeatures(esrijson); + expect(array.length).to.equal(result.length); + var got, exp, gotProp, expProp; + for (var i = 0, ii = array.length; i < ii; ++i) { + got = array[i]; + exp = result[i]; + expect(got.getGeometry().getCoordinates()).to.eql( + exp.getGeometry().getCoordinates()); + gotProp = got.getProperties(); + delete gotProp.geometry; + expProp = exp.getProperties(); + delete expProp.geometry; + expect(gotProp).to.eql(expProp); + } + }); + + it('transforms and encodes feature collection', function() { + var str = JSON.stringify(data), + array = format.readFeatures(str); + var esrijson = format.writeFeatures(array, { + featureProjection: 'EPSG:3857', + dataProjection: 'EPSG:4326' + }); + var result = format.readFeatures(esrijson); + var got, exp; + for (var i = 0, ii = array.length; i < ii; ++i) { + got = array[i]; + exp = result[i]; + expect(got.getGeometry().transform('EPSG:3857', 'EPSG:4326') + .getCoordinates()).to.eql(exp.getGeometry().getCoordinates()); + } + }); + + it('writes out a feature with a different geometryName correctly', + function() { + var feature = new ol.Feature({'foo': 'bar'}); + feature.setGeometryName('mygeom'); + feature.setGeometry(new ol.geom.Point([5, 10])); + var esrijson = format.writeFeaturesObject([feature]); + expect(esrijson.features[0].attributes.mygeom).to.eql(undefined); + }); + + it('writes out a feature without properties correctly', function() { + var feature = new ol.Feature(new ol.geom.Point([5, 10])); + var esrijson = format.writeFeatureObject(feature); + expect(esrijson.attributes).to.eql({}); + }); + + }); + });