diff --git a/config/settings.json b/config/settings.json index ceaf42bda..dc96652e3 100644 --- a/config/settings.json +++ b/config/settings.json @@ -52,6 +52,32 @@ "methods": ["GET", "POST", "PUT", "DELETE"], "encode": false }, + "projections": [ + { + "code": "EPSG:3031", + "extent": [ + -12369000, + -12369000, + 12369000, + 12369000 + ], + "isGlobal": false, + "proj4": "+proj=stere +lat_0=-90 +lat_ts=-71 +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs", + "title": "Antarctic Polar Stereographic" + }, + { + "code": "EPSG:3413", + "extent": [ + -12369000, + -12369000, + 12369000, + 12369000 + ], + "isGlobal": false, + "proj4": "+proj=stere +lat_0=90 +lat_ts=70 +lon_0=-45 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs", + "title": "NSIDC Sea Ice Polar Stereographic North" + } + ], "providers": { "basemap": { "type": "basemap", diff --git a/externs/jsts.externs.js b/externs/jsts.externs.js index cc8375308..925ed3ca9 100644 --- a/externs/jsts.externs.js +++ b/externs/jsts.externs.js @@ -193,6 +193,12 @@ jsts.geom.Geometry.prototype.getCoordinate = function() {}; jsts.geom.Geometry.prototype.getCoordinates = function() {}; +/** + * @return {jsts.geom.Point} + */ +jsts.geom.Geometry.prototype.getInteriorPoint = function() {}; + + /** * @return {number} */ @@ -340,6 +346,12 @@ jsts.geom.GeometryFactory.prototype.createMultiPolygon = function(polygons) {}; jsts.geom.GeometryFactory.prototype.createGeometryCollection = function(geometries) {}; +/** + * @param {jsts.Collection} geom + * @return {Array} + */ +jsts.geom.GeometryFactory.toPolygonArray = function(geom) {}; + /** * @param {Array} geometries @@ -454,6 +466,11 @@ jsts.geom.Polygon.prototype.getInteriorRingN = function(n) {}; jsts.geom.Polygon.prototype.getNumInteriorRing = function() {}; +/** + * @return {jsts.geom.LineString} + */ +jsts.geom.Polygon.prototype.getBoundary = function() {}; + /** * @param {Array} geometries @@ -560,3 +577,16 @@ jsts.operation.polygonize.Polygonizer.prototype.add = function(g) {}; * @return {jsts.Collection} */ jsts.operation.polygonize.Polygonizer.prototype.getPolygons = function() {}; + + +/** + * @type {Object} + */ +jsts.LineStringExtracter = {}; + + +/** + * @param {jsts.geom.Geometry} geom + * @return {jsts.Collection} + */ +jsts.LineStringExtracter.getLines = function(geom) {}; diff --git a/package.json b/package.json index 56688244f..c1cb22410 100644 --- a/package.json +++ b/package.json @@ -276,7 +276,7 @@ "jquery": "3.3.1", "js-polyfills": "^0.1.42", "jschardet": "^1.6.0", - "jsts": "^1.5.0", + "jsts": "^2.0.4", "modernizr": "~3.3.x", "moment": "~2.20.1", "navigator.sendbeacon": "~0.0.x", diff --git a/src/os/geo/geo2.js b/src/os/geo/geo2.js index d6af0a80b..36b52d020 100644 --- a/src/os/geo/geo2.js +++ b/src/os/geo/geo2.js @@ -173,3 +173,43 @@ os.geo2.normalizeGeometryCoordinates = function(geometry, opt_to, opt_proj) { return false; }; + +/** + * Check if a polygon caps one of the poles + * + * @param {ol.geom.Polygon} polygon + * @return {boolean} + */ +os.geo2.isPolarPolygon = function(polygon) { + var proj = /** @type {ol.ProjectionLike} */ (polygon.get(os.geom.GeometryField.PROJECTION) || os.map.PROJECTION); + var rings = polygon.getCoordinates(); + return rings.length ? os.geo2.isPolarRing(rings[0], proj) : false; +}; + + +/** + * Check if a polygon caps one of the poles + * + * @param {Array>} ring The polygon's exterior ring + * @param {ol.ProjectionLike=} opt_proj + * @return {boolean} if the polygon caps a pole + */ +os.geo2.isPolarRing = function(ring, opt_proj) { + opt_proj = ol.proj.get(opt_proj) || os.map.PROJECTION; + var extent = opt_proj.getExtent(); + var width = extent[2] - extent[0]; + var total = 0; + if (ring) { + for (var i = 1, n = ring.length; i < n; i++) { + var dx = ring[i][0] - ring[i - 1][0]; + if (dx > extent[2]) { + dx -= width; + } else if (dx < extent[0]) { + dx += width; + } + total += dx; + } + } + + return Math.abs(total) > 1; +}; diff --git a/src/os/geo/jsts.js b/src/os/geo/jsts.js index 4a7f5c7ee..c788a05a1 100644 --- a/src/os/geo/jsts.js +++ b/src/os/geo/jsts.js @@ -522,6 +522,198 @@ os.geo.jsts.toPolygon = function(geometry) { }; +/** + * @param {jsts.geom.Geometry} geometry + * @return {Array} + * @private + */ +os.geo.jsts.polygonize_ = function(geometry) { + var lines = jsts.LineStringExtracter.getLines(geometry); + var polygonizer = new jsts.operation.polygonize.Polygonizer(); + polygonizer.add(/** @type {jsts.Collection} */ (lines)); + var polys = polygonizer.getPolygons(); + return jsts.geom.GeometryFactory.toPolygonArray(polys); +}; + + +/** + * @param {ol.geom.Polygon|ol.geom.MultiPolygon} polygon + * @param {ol.geom.LineString} line + * @return {ol.geom.Polygon|ol.geom.MultiPolygon} + */ +os.geo.jsts.splitPolygonByLine = function(polygon, line) { + if (polygon && line) { + var olp = os.geo.jsts.OLParser.getInstance(); + var jstsPolygon = olp.read(polygon); + var jstsLine = olp.read(line); + + if (jstsPolygon && jstsLine) { + var nodedLinework = jstsPolygon.getBoundary().union(jstsLine); + var polys = os.geo.jsts.polygonize_(nodedLinework); + + // only keep polygons which are inside the input + polys = polys.filter(function(poly) { + return jstsPolygon.contains(poly.getInteriorPoint()); + }); + + polys = /** @type {Array} */ (polys.map(olp.write.bind(olp))); + + var rings = []; + for (var i = 0, n = polys.length; i < n; i++) { + rings = rings.concat(polys[i].getCoordinates()); + } + + if (polys.length > 1) { + var multi = new ol.geom.MultiPolygon([]); + multi.setPolygons(polys); + return multi; + } else if (polys.length > 0) { + return polys[0]; + } + } + } + + return polygon; +}; + + +/** + * @param {Array} polygons + * @private + */ +os.geo.jsts.flattenPolys_ = function(polygons) { + // do not cache length here as it is changing + for (var i = 0; i < polygons.length; i++) { + var item = polygons[i]; + if (item.getType() === ol.geom.GeometryType.MULTI_POLYGON) { + var args = [i, 1].concat(/** @type {ol.geom.MultiPolygon} */ (item).getPolygons()); + Array.prototype.splice.apply(polygons, args); + } + } +}; + + +/** + * @param {ol.geom.Polygon} polygon + * @return {boolean} + */ +os.geo.jsts.needsSplit = function(polygon) { + var coords = polygon.getCoordinates()[0]; + var proj = ol.proj.get(/** @type {string} */ (polygon.get(os.geom.GeometryField.PROJECTION)) || + os.map.PROJECTION); + + if (os.geo2.isPolarRing(coords, proj)) { + return true; + } + + var extent = proj.getExtent(); + var halfWidth = (extent[2] - extent[0]) / 2; + + for (var i = 1, n = coords.length; i < n; i++) { + if (Math.abs(coords[i][0] - coords[i - 1][0]) > halfWidth) { + return true; + } + } + + return false; +}; + + +proj4.defs('EPSG:3031', + '+proj=stere +lat_0=-90 +lat_ts=-71 +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs'); +proj4.defs('EPSG:3413', + '+proj=stere +lat_0=90 +lat_ts=70 +lon_0=-45 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs'); + + +/** + * @param {ol.geom.Polygon|ol.geom.MultiPolygon} polygon + * @return {ol.geom.Polygon|ol.geom.MultiPolygon} + */ +os.geo.jsts.splitPolarPolygon = function(polygon) { + if (polygon.getType() === ol.geom.GeometryType.MULTI_POLYGON) { + var multi = /** @type {ol.geom.MultiPolygon} */ (polygon); + var polygons = multi.getPolygons(); + polygons = polygons.map(os.geo.jsts.splitPolarPolygon); + os.geo.jsts.flattenPolys_(polygons); + multi.setPolygons(polygons); + return multi; + } else if (os.geo.jsts.needsSplit(/** @type {ol.geom.Polygon} */ (polygon))) { + var north = ol.extent.getCenter(polygon.getExtent())[1] >= 0; + polygon.toLonLat(); + var pLonLat = ol.proj.get(os.proj.EPSG4326); + var pPolar = ol.proj.get(north ? 'EPSG:3413' : 'EPSG:3031'); + var pFinal = os.map.PROJECTION; + + polygon.transform(pLonLat, pPolar); + var meridian = new ol.geom.LineString([[0, 0], [0, north ? 90 : -90], [180, 0]]); + meridian.transform(pLonLat, pPolar); + + var result = os.geo.jsts.splitPolygonByLine(polygon, meridian); + result.transform(pPolar, pLonLat); + + os.geo2.normalizeGeometryCoordinates(result, 0, os.proj.EPSG4326); + + if (result.getType() === ol.geom.GeometryType.MULTI_POLYGON) { + var polys = result.getCoordinates(); + + // The pole point needs go "straight up" a EPSG:4326 projection to the pole, which really + // means that we need two pole points, with one matching the longitude of the previous non-pole + // point and one matching the longitude of the next non-pole point. + for (var p = 0, pp = polys.length; p < pp; p++) { + var rings = polys[p]; + for (var r = 0, rr = rings.length; r < rr; r++) { + var ring = rings[r]; + + // not caching ring length because we are changing it + var sumLon = 0; + var numLons = 0; + for (var i = 0; i < ring.length; i++) { + var lon = ring[i][0]; + + if (Math.abs(Math.abs(lon) - 180) > 1E-12) { + sumLon += lon; + numLons++; + } + + if (Math.abs(Math.abs(ring[i][1]) - 90) < 1E-12) { + var idx = goog.math.modulo(i - 1, ring.length); + var before = ring[i].slice(); + before[0] = ring[idx][0]; + + idx = goog.math.modulo(i + 1, ring.length); + var after = ring[i].slice(); + after[0] = ring[idx][0]; + + ring.splice(i, 1, before, after); + i++; + } + } + + // Normalization puts all antimeridian coordinates at -180. For eastern + // hemisphere polygons, we need those to be 180 + if (sumLon / numLons >= 0) { + var n = ring.length; + for (i = 0; i < n; i++) { + if (Math.abs(Math.abs(ring[i][0]) - 180) < 1E-12) { + ring[i][0] = 180; + } + } + } + } + } + + result.setCoordinates(polys); + result.transform(pLonLat, pFinal); + result.set(os.geom.GeometryField.NORMALIZED, true); + result.set(os.geom.GeometryField.POLE, north); + } + return result; + } + + return polygon; +}; + + /** * Adds the target geometry to the source geometry. * @@ -1132,6 +1324,14 @@ os.geo.jsts.validate = function(geometry, opt_quiet, opt_undefinedIfInvalid) { return undefined; } + var pole = geometry.get(os.geom.GeometryField.POLE); + if (pole != null) { + // If a geometry was split over the pole by os.geo.jsts.splitPolarPolygon then each of the polygons in the + // MultiPolygon are valid. The MultiPolygon is technically not valid because they touch on a segment (the + // segment(s) making up the meridian/antimeridian). + return geometry; + } + var geomType = geometry.getType(); if (geomType == ol.geom.GeometryType.POLYGON || geomType == ol.geom.GeometryType.MULTI_POLYGON) { try { diff --git a/src/os/geom/geometryfield.js b/src/os/geom/geometryfield.js index bfde54449..81432d175 100644 --- a/src/os/geom/geometryfield.js +++ b/src/os/geom/geometryfield.js @@ -7,5 +7,7 @@ goog.provide('os.geom.GeometryField'); */ os.geom.GeometryField = { DIRTY: '_dirty', - NORMALIZED: '_normalized' + NORMALIZED: '_normalized', + POLE: '_pole', + PROJECTION: '_projection' }; diff --git a/src/os/mixin/geometrymixin.js b/src/os/mixin/geometrymixin.js index 07abf9b9d..ba910d8a3 100644 --- a/src/os/mixin/geometrymixin.js +++ b/src/os/mixin/geometrymixin.js @@ -145,7 +145,22 @@ ol.geom.Geometry.prototype.toLonLat = function() { }; + (function() { + var oldTransform = ol.geom.Geometry.prototype.transform; + + /** + * @param {ol.ProjectionLike} pFrom + * @param {ol.ProjectionLike} pTo + * @return {ol.geom.Geometry} + */ + ol.geom.Geometry.prototype.transform = function(pFrom, pTo) { + var to = ol.proj.get(pTo); + this.set(os.geom.GeometryField.PROJECTION, to.getCode()); + return oldTransform.call(this, pFrom, to); + }; + + /** * Openlayers' implementation does not actually clone the underlying geometries * @return {!ol.geom.GeometryCollection} The clone diff --git a/src/os/query/areamanager.js b/src/os/query/areamanager.js index 5b29ee21b..fcf30c89e 100644 --- a/src/os/query/areamanager.js +++ b/src/os/query/areamanager.js @@ -357,21 +357,6 @@ os.query.AreaManager.prototype.addInternal = function(feature, opt_bulk) { }; -/** - * @inheritDoc - */ -os.query.AreaManager.prototype.normalizeGeometry = function(feature) { - // TODO: I'd prefer to have the normalizeGeometryCoordinate method take an optional projection - // rather than converting to/from EPSG:4326 - var geom = feature.getGeometry(); - - if (!geom.get(os.geom.GeometryField.NORMALIZED)) { - os.geo2.normalizeGeometryCoordinates(geom); - feature.setGeometry(geom); - } -}; - - /** * Converts all color types to a standard rgba string. * diff --git a/src/os/query/baseareamanager.js b/src/os/query/baseareamanager.js index 14d2bfc51..0f48c9da6 100644 --- a/src/os/query/baseareamanager.js +++ b/src/os/query/baseareamanager.js @@ -249,6 +249,10 @@ os.query.BaseAreaManager.prototype.isValidFeature = function(feature) { var isValid = false; var geomType = geometry.getType(); if (geomType == ol.geom.GeometryType.POLYGON || geomType == ol.geom.GeometryType.MULTI_POLYGON) { + geometry = os.geo.jsts.splitPolarPolygon( + /** @type {ol.geom.Polygon|ol.geom.MultiPolygon} */ (geometry)); + os.interpolate.interpolateGeom(geometry); + var validated = os.geo.jsts.validate(geometry); var validatedOriginal = null; @@ -274,22 +278,13 @@ os.query.BaseAreaManager.prototype.isValidFeature = function(feature) { } if (isValid) { - this.normalizeGeometry(feature); + os.geo2.normalizeGeometryCoordinates(feature.getGeometry()); } return isValid; }; -/** - * @param {ol.Feature} feature - * @protected - */ -os.query.BaseAreaManager.prototype.normalizeGeometry = function(feature) { - os.geo2.normalizeGeometryCoordinates(feature.getGeometry()); -}; - - /** * filter the list of features to only include valid areas * @param {Array} features diff --git a/src/os/source/vectorsource.js b/src/os/source/vectorsource.js index 7c3c0715a..5c8e3063f 100644 --- a/src/os/source/vectorsource.js +++ b/src/os/source/vectorsource.js @@ -1951,12 +1951,13 @@ os.source.Vector.prototype.processImmediate = function(feature) { geom = os.geo.splitOnDateLine(/** @type {!(ol.geom.LineString|ol.geom.MultiLineString)} */ (geom)); geom.osTransform(); feature.setGeometry(geom); - } else if (!geom.get(os.geom.GeometryField.NORMALIZED)) { - // normalize non-point geometries unless they were normalized elsewhere - os.geo2.normalizeGeometryCoordinates(geom); + } else if (geomType === ol.geom.GeometryType.POLYGON || geomType === ol.geom.GeometryType.MULTI_POLYGON) { + geom = os.geo.jsts.splitPolarPolygon( + /** @type {ol.geom.Polygon|ol.geom.MultiPolygon} */ (geom)); } } + os.geo2.normalizeGeometryCoordinates(geom); os.interpolate.interpolateFeature(feature); // make sure the internal feature ID field is set diff --git a/src/os/ui/ol/interaction/dragcircleinteraction.js b/src/os/ui/ol/interaction/dragcircleinteraction.js index 9d17e0b45..1c4419100 100644 --- a/src/os/ui/ol/interaction/dragcircleinteraction.js +++ b/src/os/ui/ol/interaction/dragcircleinteraction.js @@ -55,7 +55,9 @@ os.ui.ol.interaction.DragCircle.prototype.getGeometry = function() { var geom = this.circle2D.getOriginalGeometry(); if (geom) { + geom = os.geo.jsts.splitPolarPolygon(geom); os.geo2.normalizeGeometryCoordinates(geom); + os.interpolate.interpolateGeom(geom); } return geom; diff --git a/src/os/ui/ol/interaction/drawpolygoninteraction.js b/src/os/ui/ol/interaction/drawpolygoninteraction.js index 0982cf5a2..35b629fb9 100644 --- a/src/os/ui/ol/interaction/drawpolygoninteraction.js +++ b/src/os/ui/ol/interaction/drawpolygoninteraction.js @@ -76,20 +76,9 @@ os.ui.ol.interaction.DrawPolygon.prototype.getGeometry = function() { var geom = new ol.geom.Polygon([this.coords]); var method = os.interpolate.getMethod(); geom.set(os.interpolate.METHOD_FIELD, method); - geom.toLonLat(); - - // normalize coordinates prior to validation, or polygons crossing the date line may be broken - os.geo2.normalizeGeometryCoordinates(geom, undefined, os.proj.EPSG4326); - - // then interpolate so the coordinates reflect what was drawn - os.interpolate.beginTempInterpolation(os.proj.EPSG4326, method); + geom = os.geo.jsts.splitPolarPolygon(geom); + os.geo2.normalizeGeometryCoordinates(geom); os.interpolate.interpolateGeom(geom); - os.interpolate.endTempInterpolation(); - - // finally validate the geometry to ensure it's accepted in server queries - geom = os.geo.jsts.validate(geom); - - geom.osTransform(); return geom; }; diff --git a/src/plugin/ogc/query/ogcspatialformatter.js b/src/plugin/ogc/query/ogcspatialformatter.js index 41ff85fb1..d625ccedb 100644 --- a/src/plugin/ogc/query/ogcspatialformatter.js +++ b/src/plugin/ogc/query/ogcspatialformatter.js @@ -1,8 +1,10 @@ goog.provide('plugin.ogc.query.OGCSpatialFormatter'); +goog.require('ol.geom.GeometryType'); +goog.require('ol.geom.LineString'); +goog.require('os.geo'); +goog.require('os.geo2'); goog.require('os.ogc.filter.OGCSpatialFormatter'); - - /** * @param {string=} opt_column * @extends {os.ogc.filter.OGCSpatialFormatter} @@ -22,7 +24,18 @@ plugin.ogc.query.OGCSpatialFormatter.prototype.getGeometry = function(feature) { if (geom) { geom = geom.clone().toLonLat(); - os.geo2.normalizeGeometryCoordinates(geom, undefined, os.proj.EPSG4326); + var target = undefined; + var type = geom.getType(); + + if ((type === ol.geom.GeometryType.POLYGON || type === ol.geom.GeometryType.MULTI_POLYGON) + && os.geo.crossesDateLine(geom)) { + var antimeridian = geom.getExtent()[0] >= -180 ? 180 : -180; + geom = os.geo.jsts.splitPolygonByLine(/** @type {ol.geom.Polygon|ol.geom.MultiPolygon} */ (geom), + new ol.geom.LineString([[antimeridian, -90], [antimeridian, 90]])); + target = 0; + } + + os.geo2.normalizeGeometryCoordinates(geom, target, os.proj.EPSG4326); } return geom; diff --git a/test/os/geo/jsts.test.js b/test/os/geo/jsts.test.js new file mode 100644 index 000000000..68bf6bff2 --- /dev/null +++ b/test/os/geo/jsts.test.js @@ -0,0 +1,107 @@ +goog.require('ol.geom.LineString'); +goog.require('ol.geom.MultiPolygon'); +goog.require('ol.geom.Polygon'); +goog.require('os.geo.jsts'); +goog.require('os.proj'); + +describe('os.geo.jsts', function() { + it('should split polygons properly', function() { + var polygon = new ol.geom.Polygon.fromExtent([-2, -2, 2, 2]); + var line = new ol.geom.LineString([[0, -4], [0, 4]]); + + var result = os.geo.jsts.splitPolygonByLine(polygon, line); + expect(result instanceof ol.geom.MultiPolygon).toBe(true); + var polys = result.getPolygons(); + expect(polys.length).toBe(2); + + expect(polys[0].getExtent()).toEqual([-2, -2, 0, 2]); + expect(polys[1].getExtent()).toEqual([0, -2, 2, 2]); + + polys.forEach(function(poly) { + expect(os.geo.isClosed(poly.getCoordinates()[0])).toBe(true); + }); + }); + + it('should split polygons with a z-coord properly', function() { + var polygon = new ol.geom.Polygon([[ + [-2, -2, 2], + [2, -2, 4], + [2, 2, 4], + [-2, 2, 2], + [-2, -2, 2]]]); + + var line = new ol.geom.LineString([[0, -4, 0], [0, 4, 0]]); + + var result = os.geo.jsts.splitPolygonByLine(polygon, line); + expect(result instanceof ol.geom.MultiPolygon).toBe(true); + var polys = result.getPolygons(); + expect(polys.length).toBe(2); + + expect(polys[0].getExtent()).toEqual([-2, -2, 0, 2]); + expect(polys[1].getExtent()).toEqual([0, -2, 2, 2]); + + polys.forEach(function(poly) { + var rings = poly.getCoordinates(); + expect(os.geo.isClosed(rings[0])).toBe(true); + + for (var i = 0, n = rings[0].length; i < n; i++) { + var coord = rings[0][i]; + if (coord[0] === -2) { + expect(coord[2]).toBe(2); + } else if (coord[0] === 0) { + expect(coord[2]).toBe(3); + } else if (coord[0] === 2) { + expect(coord[2]).toBe(4); + } + } + }); + }); + + it('should split north polar polygons properly', function() { + proj4.defs('EPSG:3413', + '+proj=stere +lat_0=90 +lat_ts=70 +lon_0=-45 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs'); + + var polygon = new ol.geom.Polygon([[ + [0, 85], + [90, 85], + [180, 85], + [270, 85], + [0, 85]]]); + + var result = os.geo.jsts.splitPolarPolygon(polygon); + expect(result instanceof ol.geom.MultiPolygon).toBe(true); + var polys = result.getPolygons(); + expect(polys.length).toBe(2); + + expect(polys[0].getExtent()).toEqual([-180, 85, 0, 90]); + expect(polys[1].getExtent()).toEqual([0, 85, 180, 90]); + + polys.forEach(function(poly) { + expect(os.geo.isClosed(poly.getCoordinates()[0])).toBe(true); + }); + }); + + it('should split south polar polygons properly', function() { + proj4.defs('EPSG:3031', + '+proj=stere +lat_0=-90 +lat_ts=-71 +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs'); + + var polygon = new ol.geom.Polygon([[ + [0, -85], + [90, -85], + [180, -85], + [270, -85], + [0, -85]]]); + + var result = os.geo.jsts.splitPolarPolygon(polygon); + expect(result instanceof ol.geom.MultiPolygon).toBe(true); + var polys = result.getPolygons(); + expect(polys.length).toBe(2); + + expect(polys[0].getExtent()).toEqual([-180, -90, 0, -85]); + expect(polys[1].getExtent()).toEqual([0, -90, 180, -85]); + + polys.forEach(function(poly) { + expect(os.geo.isClosed(poly.getCoordinates()[0])).toBe(true); + }); + }); +});