Browse Source

Support geo label position as runtime field (#86154)

We do this differently for each geometry type:

* For points we return the point
* For multipoint the centroid is unlikely to be one of the points,
  so we choose a point closest to the middle of the bounding box.
* For linestring we choose the first line-segment in the triangle tree, and return its center.
* For polygons we choose the centroid, but check if it is contained within the polygon.
   If not, we choose the first triangle in the triangle tree and return its center (average point)

The use of the first entry in the triangle tree is a technique to get a likely approximate center,
while also being high performance.
Craig Taverner 3 years ago
parent
commit
36fd6404c7
18 changed files with 1093 additions and 15 deletions
  1. 5 0
      docs/changelog/86154.yaml
  2. 1 0
      modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.txt
  3. 15 3
      modules/lang-painless/src/yamlRestTest/resources/rest-api-spec/test/painless/50_script_doc_values.yml
  4. 59 0
      server/src/internalClusterTest/java/org/elasticsearch/search/geo/GeoPointScriptDocValuesIT.java
  5. 10 0
      server/src/main/java/org/elasticsearch/index/fielddata/ScriptDocValues.java
  6. 9 9
      server/src/main/java/org/elasticsearch/index/mapper/GeoShapeQueryable.java
  7. 19 0
      server/src/main/java/org/elasticsearch/script/field/GeoPointDocValuesField.java
  8. 118 1
      x-pack/plugin/spatial/src/internalClusterTest/java/org/elasticsearch/xpack/spatial/search/GeoShapeScriptDocValuesIT.java
  9. 38 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/GeoShapeValues.java
  10. 93 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/LabelPositionVisitor.java
  11. 5 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/plain/AbstractAtomicGeoShapeShapeFieldData.java
  12. 10 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/mapper/GeoShapeWithDocValuesFieldMapper.java
  13. 156 2
      x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/index/fielddata/GeometryDocValueTests.java
  14. BIN
      x-pack/plugin/spatial/src/test/resources/org/elasticsearch/xpack/spatial/index/fielddata/Antarctica.wkt.gz
  15. BIN
      x-pack/plugin/spatial/src/test/resources/org/elasticsearch/xpack/spatial/index/fielddata/France.wkt.gz
  16. 540 0
      x-pack/plugin/spatial/src/test/resources/org/elasticsearch/xpack/spatial/index/fielddata/LICENSE.txt
  17. 2 0
      x-pack/plugin/spatial/src/test/resources/org/elasticsearch/xpack/spatial/index/fielddata/NOTICE.txt
  18. 13 0
      x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/spatial/70_script_doc_values.yml

+ 5 - 0
docs/changelog/86154.yaml

@@ -0,0 +1,5 @@
+pr: 86154
+summary: Support geo label position as runtime field
+area: Geo
+type: enhancement
+issues: []

+ 1 - 0
modules/lang-painless/src/main/resources/org/elasticsearch/painless/org.elasticsearch.txt

@@ -85,6 +85,7 @@ class org.elasticsearch.index.fielddata.ScriptDocValues$Geometry {
   int getDimensionalType()
   org.elasticsearch.common.geo.GeoPoint getCentroid()
   org.elasticsearch.common.geo.GeoBoundingBox getBoundingBox()
+  org.elasticsearch.common.geo.GeoPoint getLabelPosition()
   double getMercatorWidth()
   double getMercatorHeight()
 }

+ 15 - 3
modules/lang-painless/src/yamlRestTest/resources/rest-api-spec/test/painless/50_script_doc_values.yml

@@ -638,6 +638,18 @@ setup:
     - match: { hits.hits.0.fields.bbox.0.bottom_right.lat: 41.1199999647215 }
     - match: { hits.hits.0.fields.bbox.0.bottom_right.lon: -71.34000004269183 }
 
+    - do:
+        search:
+          rest_total_hits_as_int: true
+          body:
+            query: { term: { _id: "1" } }
+            script_fields:
+              label_position:
+                script:
+                  source: "doc['geo_point'].getLabelPosition()"
+    - match: { hits.hits.0.fields.label_position.0.lat: 41.1199999647215 }
+    - match: { hits.hits.0.fields.label_position.0.lon: -71.34000004269183 }
+
     - do:
         search:
           rest_total_hits_as_int: true
@@ -661,9 +673,9 @@ setup:
           body:
             query: { term: { _id: "1" } }
             script_fields:
-                    type:
-                        script:
-                            source: "doc['geo_point'].getDimensionalType()"
+              type:
+                script:
+                  source: "doc['geo_point'].getDimensionalType()"
     - match: { hits.hits.0.fields.type.0: 0 }
 
     - do:

+ 59 - 0
server/src/internalClusterTest/java/org/elasticsearch/search/geo/GeoPointScriptDocValuesIT.java

@@ -12,6 +12,7 @@ import org.apache.lucene.geo.GeoEncodingUtils;
 import org.elasticsearch.action.search.SearchResponse;
 import org.elasticsearch.common.document.DocumentField;
 import org.elasticsearch.common.geo.GeoBoundingBox;
+import org.elasticsearch.common.geo.GeoPoint;
 import org.elasticsearch.geo.GeometryTestUtils;
 import org.elasticsearch.index.fielddata.ScriptDocValues;
 import org.elasticsearch.plugins.Plugin;
@@ -21,6 +22,8 @@ import org.elasticsearch.script.ScriptType;
 import org.elasticsearch.test.ESSingleNodeTestCase;
 import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentFactory;
+import org.hamcrest.BaseMatcher;
+import org.hamcrest.Description;
 import org.hamcrest.Matchers;
 import org.junit.Before;
 
@@ -36,6 +39,8 @@ import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcke
 import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertSearchResponse;
 import static org.elasticsearch.xcontent.XContentFactory.jsonBuilder;
 import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.oneOf;
 
 public class GeoPointScriptDocValuesIT extends ESSingleNodeTestCase {
 
@@ -54,6 +59,8 @@ public class GeoPointScriptDocValuesIT extends ESSingleNodeTestCase {
             scripts.put("lon", this::scriptLon);
             scripts.put("height", this::scriptHeight);
             scripts.put("width", this::scriptWidth);
+            scripts.put("label_lat", this::scriptLabelLat);
+            scripts.put("label_lon", this::scriptLabelLon);
             return scripts;
         }
 
@@ -91,15 +98,29 @@ public class GeoPointScriptDocValuesIT extends ESSingleNodeTestCase {
             return geometry.size() == 0 ? Double.NaN : geometry.getCentroid().lon();
         }
 
+        private double scriptLabelLat(Map<String, Object> vars) {
+            Map<?, ?> doc = (Map<?, ?>) vars.get("doc");
+            ScriptDocValues.Geometry<?> geometry = assertGeometry(doc);
+            return geometry.size() == 0 ? Double.NaN : geometry.getLabelPosition().lat();
+        }
+
+        private double scriptLabelLon(Map<String, Object> vars) {
+            Map<?, ?> doc = (Map<?, ?>) vars.get("doc");
+            ScriptDocValues.Geometry<?> geometry = assertGeometry(doc);
+            return geometry.size() == 0 ? Double.NaN : geometry.getLabelPosition().lon();
+        }
+
         private ScriptDocValues.Geometry<?> assertGeometry(Map<?, ?> doc) {
             ScriptDocValues.Geometry<?> geometry = (ScriptDocValues.Geometry<?>) doc.get("location");
             if (geometry.size() == 0) {
                 assertThat(geometry.getBoundingBox(), Matchers.nullValue());
                 assertThat(geometry.getCentroid(), Matchers.nullValue());
+                assertThat(geometry.getLabelPosition(), Matchers.nullValue());
                 assertThat(geometry.getDimensionalType(), equalTo(-1));
             } else {
                 assertThat(geometry.getBoundingBox(), Matchers.notNullValue());
                 assertThat(geometry.getCentroid(), Matchers.notNullValue());
+                assertThat(geometry.getLabelPosition(), Matchers.notNullValue());
                 assertThat(geometry.getDimensionalType(), equalTo(0));
             }
             return geometry;
@@ -140,6 +161,8 @@ public class GeoPointScriptDocValuesIT extends ESSingleNodeTestCase {
             .addScriptField("lon", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "lon", Collections.emptyMap()))
             .addScriptField("height", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "height", Collections.emptyMap()))
             .addScriptField("width", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "width", Collections.emptyMap()))
+            .addScriptField("label_lat", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "label_lat", Collections.emptyMap()))
+            .addScriptField("label_lon", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "label_lon", Collections.emptyMap()))
             .get();
         assertSearchResponse(searchResponse);
 
@@ -151,6 +174,10 @@ public class GeoPointScriptDocValuesIT extends ESSingleNodeTestCase {
         assertThat(fields.get("lon").getValue(), equalTo(qLon));
         assertThat(fields.get("height").getValue(), equalTo(0d));
         assertThat(fields.get("width").getValue(), equalTo(0d));
+
+        // Check label position is the same point
+        assertThat(fields.get("label_lon").getValue(), equalTo(qLon));
+        assertThat(fields.get("label_lat").getValue(), equalTo(qLat));
     }
 
     public void testRandomMultiPoint() throws Exception {
@@ -178,6 +205,8 @@ public class GeoPointScriptDocValuesIT extends ESSingleNodeTestCase {
             .addScriptField("lon", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "lon", Collections.emptyMap()))
             .addScriptField("height", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "height", Collections.emptyMap()))
             .addScriptField("width", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "width", Collections.emptyMap()))
+            .addScriptField("label_lat", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "label_lat", Collections.emptyMap()))
+            .addScriptField("label_lon", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "label_lon", Collections.emptyMap()))
             .get();
         assertSearchResponse(searchResponse);
 
@@ -196,6 +225,11 @@ public class GeoPointScriptDocValuesIT extends ESSingleNodeTestCase {
         assertThat(fields.get("lon").getValue(), equalTo(centroidLon));
         assertThat(fields.get("height").getValue(), equalTo(height));
         assertThat(fields.get("width").getValue(), equalTo(width));
+
+        // Check label position is one of the incoming points
+        double labelLat = fields.get("label_lat").getValue();
+        double labelLon = fields.get("label_lon").getValue();
+        assertThat("Label should be one of the points", new GeoPoint(labelLat, labelLon), isMultiPointLabelPosition(lats, lons));
     }
 
     public void testNullPoint() throws Exception {
@@ -221,4 +255,29 @@ public class GeoPointScriptDocValuesIT extends ESSingleNodeTestCase {
         assertThat(fields.get("height").getValue(), equalTo(Double.NaN));
         assertThat(fields.get("width").getValue(), equalTo(Double.NaN));
     }
+
+    private static MultiPointLabelPosition isMultiPointLabelPosition(double[] lats, double[] lons) {
+        return new MultiPointLabelPosition(lats, lons);
+    }
+
+    private static class MultiPointLabelPosition extends BaseMatcher<GeoPoint> {
+        private final GeoPoint[] points;
+
+        private MultiPointLabelPosition(double[] lats, double[] lons) {
+            points = new GeoPoint[lats.length];
+            for (int i = 0; i < lats.length; i++) {
+                points[i] = new GeoPoint(lats[i], lons[i]);
+            }
+        }
+
+        @Override
+        public boolean matches(Object actual) {
+            return is(oneOf(points)).matches(actual);
+        }
+
+        @Override
+        public void describeTo(Description description) {
+            description.appendText("is(oneOf(" + Arrays.toString(points) + ")");
+        }
+    }
 }

+ 10 - 0
server/src/main/java/org/elasticsearch/index/fielddata/ScriptDocValues.java

@@ -232,6 +232,9 @@ public abstract class ScriptDocValues<T> extends AbstractList<T> {
         /** Returns the bounding box of this geometry  */
         public abstract GeoBoundingBox getBoundingBox();
 
+        /** Returns the suggested label position  */
+        public abstract GeoPoint getLabelPosition();
+
         /** Returns the centroid of this geometry  */
         public abstract GeoPoint getCentroid();
 
@@ -247,6 +250,8 @@ public abstract class ScriptDocValues<T> extends AbstractList<T> {
         GeoPoint getInternalCentroid();
 
         GeoBoundingBox getInternalBoundingBox();
+
+        GeoPoint getInternalLabelPosition();
     }
 
     public static class GeoPoints extends Geometry<GeoPoint> {
@@ -363,6 +368,11 @@ public abstract class ScriptDocValues<T> extends AbstractList<T> {
         public GeoBoundingBox getBoundingBox() {
             return size() == 0 ? null : geometrySupplier.getInternalBoundingBox();
         }
+
+        @Override
+        public GeoPoint getLabelPosition() {
+            return size() == 0 ? null : geometrySupplier.getInternalLabelPosition();
+        }
     }
 
     public static class Booleans extends ScriptDocValues<Boolean> {

+ 9 - 9
server/src/main/java/org/elasticsearch/index/mapper/GeoShapeQueryable.java

@@ -44,7 +44,12 @@ public interface GeoShapeQueryable {
     Query geoShapeQuery(SearchExecutionContext context, String fieldName, ShapeRelation relation, LatLonGeometry... luceneGeometries);
 
     default Query geoShapeQuery(SearchExecutionContext context, String fieldName, ShapeRelation relation, Geometry shape) {
-        final LatLonGeometry[] luceneGeometries = toQuantizeLuceneGeometry(fieldName, context, shape, relation);
+        final LatLonGeometry[] luceneGeometries;
+        try {
+            luceneGeometries = toQuantizeLuceneGeometry(shape, relation);
+        } catch (IllegalArgumentException e) {
+            throw new QueryShardException(context, "Exception creating query on Field [" + fieldName + "] " + e.getMessage(), e);
+        }
         if (luceneGeometries.length == 0) {
             return new MatchNoDocsQuery();
         }
@@ -82,12 +87,7 @@ public interface GeoShapeQueryable {
      * transforms an Elasticsearch {@link Geometry} into a lucene {@link LatLonGeometry} and quantize
      * the latitude and longitude values to match the values on the index.
      */
-    private static LatLonGeometry[] toQuantizeLuceneGeometry(
-        String name,
-        SearchExecutionContext context,
-        Geometry geometry,
-        ShapeRelation relation
-    ) {
+    static LatLonGeometry[] toQuantizeLuceneGeometry(Geometry geometry, ShapeRelation relation) {
         if (geometry == null) {
             return new LatLonGeometry[0];
         }
@@ -130,7 +130,7 @@ public interface GeoShapeQueryable {
                     if (relation == ShapeRelation.WITHIN) {
                         // Line geometries and WITHIN relation is not supported by Lucene. Throw an error here
                         // to have same behavior for runtime fields.
-                        throw new QueryShardException(context, "Field [" + name + "] found an unsupported shape Line");
+                        throw new IllegalArgumentException("found an unsupported shape Line");
                     }
                     geometries.add(new org.apache.lucene.geo.Line(quantizeLats(line.getLats()), quantizeLons(line.getLons())));
                 }
@@ -139,7 +139,7 @@ public interface GeoShapeQueryable {
 
             @Override
             public Void visit(LinearRing ring) {
-                throw new QueryShardException(context, "Field [" + name + "] found an unsupported shape LinearRing");
+                throw new IllegalArgumentException("Found an unsupported shape LinearRing");
             }
 
             @Override

+ 19 - 0
server/src/main/java/org/elasticsearch/script/field/GeoPointDocValuesField.java

@@ -11,6 +11,7 @@ package org.elasticsearch.script.field;
 import org.apache.lucene.util.ArrayUtil;
 import org.elasticsearch.common.geo.GeoBoundingBox;
 import org.elasticsearch.common.geo.GeoPoint;
+import org.elasticsearch.common.geo.GeoUtils;
 import org.elasticsearch.index.fielddata.MultiGeoPointValues;
 import org.elasticsearch.index.fielddata.ScriptDocValues;
 
@@ -34,6 +35,7 @@ public class GeoPointDocValuesField extends AbstractScriptFieldFactory<GeoPoint>
     private ScriptDocValues.GeoPoints geoPoints = null;
     private final GeoPoint centroid = new GeoPoint();
     private final GeoBoundingBox boundingBox = new GeoBoundingBox(new GeoPoint(), new GeoPoint());
+    private int labelIndex = 0;
 
     public GeoPointDocValuesField(MultiGeoPointValues input, String name) {
         this.input = input;
@@ -71,11 +73,13 @@ public class GeoPointDocValuesField extends AbstractScriptFieldFactory<GeoPoint>
         centroid.reset(point.lat(), point.lon());
         boundingBox.topLeft().reset(point.lat(), point.lon());
         boundingBox.bottomRight().reset(point.lat(), point.lon());
+        labelIndex = 0;
     }
 
     private void setMultiValue() throws IOException {
         double centroidLat = 0;
         double centroidLon = 0;
+        labelIndex = 0;
         double maxLon = Double.NEGATIVE_INFINITY;
         double minLon = Double.POSITIVE_INFINITY;
         double maxLat = Double.NEGATIVE_INFINITY;
@@ -89,12 +93,22 @@ public class GeoPointDocValuesField extends AbstractScriptFieldFactory<GeoPoint>
             minLon = Math.min(minLon, values[i].getLon());
             maxLat = Math.max(maxLat, values[i].getLat());
             minLat = Math.min(minLat, values[i].getLat());
+            labelIndex = closestPoint(labelIndex, i, (minLat + maxLat) / 2, (minLon + maxLon) / 2);
         }
         centroid.reset(centroidLat / count, centroidLon / count);
         boundingBox.topLeft().reset(maxLat, minLon);
         boundingBox.bottomRight().reset(minLat, maxLon);
     }
 
+    private int closestPoint(int a, int b, double lat, double lon) {
+        if (a == b) {
+            return a;
+        }
+        double distA = GeoUtils.planeDistance(lat, lon, values[a].lat(), values[a].lon());
+        double distB = GeoUtils.planeDistance(lat, lon, values[b].lat(), values[b].lon());
+        return distA < distB ? a : b;
+    }
+
     @Override
     public ScriptDocValues<GeoPoint> toScriptDocValues() {
         if (geoPoints == null) {
@@ -121,6 +135,11 @@ public class GeoPointDocValuesField extends AbstractScriptFieldFactory<GeoPoint>
         return boundingBox;
     }
 
+    @Override
+    public GeoPoint getInternalLabelPosition() {
+        return values[labelIndex];
+    }
+
     @Override
     public String getName() {
         return name;

+ 118 - 1
x-pack/plugin/spatial/src/internalClusterTest/java/org/elasticsearch/xpack/spatial/search/GeoShapeScriptDocValuesIT.java

@@ -11,9 +11,14 @@ import org.elasticsearch.common.document.DocumentField;
 import org.elasticsearch.common.geo.GeoBoundingBox;
 import org.elasticsearch.common.geo.Orientation;
 import org.elasticsearch.geo.GeometryTestUtils;
+import org.elasticsearch.geometry.Circle;
 import org.elasticsearch.geometry.Geometry;
+import org.elasticsearch.geometry.Line;
 import org.elasticsearch.geometry.LinearRing;
+import org.elasticsearch.geometry.MultiPoint;
+import org.elasticsearch.geometry.Point;
 import org.elasticsearch.geometry.Polygon;
+import org.elasticsearch.geometry.utils.GeographyValidator;
 import org.elasticsearch.geometry.utils.WellKnownText;
 import org.elasticsearch.index.fielddata.ScriptDocValues;
 import org.elasticsearch.index.mapper.GeoShapeIndexer;
@@ -26,12 +31,15 @@ import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentFactory;
 import org.elasticsearch.xpack.core.LocalStateCompositeXPackPlugin;
 import org.elasticsearch.xpack.spatial.LocalStateSpatialPlugin;
+import org.elasticsearch.xpack.spatial.index.fielddata.DimensionalShapeType;
 import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues;
 import org.elasticsearch.xpack.spatial.util.GeoTestUtils;
 import org.hamcrest.Matchers;
 import org.junit.Before;
 
 import java.io.IOException;
+import java.text.ParseException;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
@@ -63,6 +71,8 @@ public class GeoShapeScriptDocValuesIT extends ESSingleNodeTestCase {
             scripts.put("lon", this::scriptLon);
             scripts.put("height", this::scriptHeight);
             scripts.put("width", this::scriptWidth);
+            scripts.put("label_lat", this::scriptLabelLat);
+            scripts.put("label_lon", this::scriptLabelLon);
             return scripts;
         }
 
@@ -100,15 +110,29 @@ public class GeoShapeScriptDocValuesIT extends ESSingleNodeTestCase {
             return geometry.size() == 0 ? Double.NaN : geometry.getCentroid().lon();
         }
 
+        private double scriptLabelLat(Map<String, Object> vars) {
+            Map<?, ?> doc = (Map<?, ?>) vars.get("doc");
+            ScriptDocValues.Geometry<?> geometry = assertGeometry(doc);
+            return geometry.size() == 0 ? Double.NaN : geometry.getLabelPosition().lat();
+        }
+
+        private double scriptLabelLon(Map<String, Object> vars) {
+            Map<?, ?> doc = (Map<?, ?>) vars.get("doc");
+            ScriptDocValues.Geometry<?> geometry = assertGeometry(doc);
+            return geometry.size() == 0 ? Double.NaN : geometry.getLabelPosition().lon();
+        }
+
         private ScriptDocValues.Geometry<?> assertGeometry(Map<?, ?> doc) {
             ScriptDocValues.Geometry<?> geometry = (ScriptDocValues.Geometry<?>) doc.get("location");
             if (geometry.size() == 0) {
                 assertThat(geometry.getBoundingBox(), Matchers.nullValue());
                 assertThat(geometry.getCentroid(), Matchers.nullValue());
+                assertThat(geometry.getLabelPosition(), Matchers.nullValue());
                 assertThat(geometry.getDimensionalType(), equalTo(-1));
             } else {
                 assertThat(geometry.getBoundingBox(), Matchers.notNullValue());
                 assertThat(geometry.getCentroid(), Matchers.notNullValue());
+                assertThat(geometry.getLabelPosition(), Matchers.notNullValue());
                 assertThat(geometry.getDimensionalType(), greaterThanOrEqualTo(0));
                 assertThat(geometry.getDimensionalType(), lessThanOrEqualTo(2));
             }
@@ -147,12 +171,79 @@ public class GeoShapeScriptDocValuesIT extends ESSingleNodeTestCase {
         doTestGeometry(geometry);
     }
 
+    public void testPolygonFromYamlTests() throws IOException, ParseException {
+        // This is the geometry used in the tests in 70_script_doc_values.yml, and is easier to test and debug here
+        String wkt = "POLYGON(("
+            + "24.04725 59.942,24.04825 59.94125,24.04875 59.94125,24.04875 59.94175,24.048 59.9425,"
+            + "24.0475 59.94275,24.0465 59.94225,24.046 59.94225,24.04575 59.9425,24.04525 59.94225,24.04725 59.942"
+            + "))";
+        Geometry polygon = WellKnownText.fromWKT(GeographyValidator.instance(true), true, wkt);
+        doTestGeometry(polygon, null);
+    }
+
     public void testPolygonDateline() throws Exception {
         Geometry geometry = new Polygon(new LinearRing(new double[] { 170, 190, 190, 170, 170 }, new double[] { -5, -5, 5, 5, -5 }));
-        doTestGeometry(geometry);
+        doTestGeometry(geometry, null);
+    }
+
+    private MultiPoint pointsFromLine(Line line) {
+        ArrayList<Point> points = new ArrayList<>();
+        for (int i = 0; i < line.length(); i++) {
+            double x = line.getX(i);
+            double y = line.getY(i);
+            points.add(new Point(x, y));
+        }
+        return new MultiPoint(points);
+    }
+
+    public void testEvenLineString() throws Exception {
+        Line line = new Line(new double[] { -5, -1, 0, 1, 5 }, new double[] { 0, 0, 0, 0, 0 });
+        doTestGeometry(line, GeoTestUtils.geoShapeValue(new Point(-0.5, 0)));
+        doTestGeometry(pointsFromLine(line), GeoTestUtils.geoShapeValue(new Point(0, 0)));
+    }
+
+    public void testOddLineString() throws Exception {
+        Line line = new Line(new double[] { -5, -1, 1, 5 }, new double[] { 0, 0, 0, 0 });
+        doTestGeometry(line, GeoTestUtils.geoShapeValue(new Point(0, 0)));
+        doTestGeometry(pointsFromLine(line), GeoTestUtils.geoShapeValue(new Point(-1, 0)));
+    }
+
+    public void testUnbalancedEvenLineString() throws Exception {
+        Line line = new Line(new double[] { -5, -4, -3, -2, -1, 0, 5 }, new double[] { 0, 0, 0, 0, 0, 0, 0 });
+        doTestGeometry(line, GeoTestUtils.geoShapeValue(new Point(-2.5, 0)));
+        doTestGeometry(pointsFromLine(line), GeoTestUtils.geoShapeValue(new Point(-2, 0)));
+    }
+
+    public void testUnbalancedOddLineString() throws Exception {
+        Line line = new Line(new double[] { -5, -4, -3, -2, -1, 5 }, new double[] { 0, 0, 0, 0, 0, 0 });
+        doTestGeometry(line, GeoTestUtils.geoShapeValue(new Point(-2.5, 0)));
+        doTestGeometry(pointsFromLine(line), GeoTestUtils.geoShapeValue(new Point(-3, 0)));
+    }
+
+    public void testVerticalLineString() throws Exception {
+        // Data with no x-range is not well sorted and odd choices occur for the first triangle tree node
+        Line line = new Line(new double[] { 0, 0, 0, 0, 0 }, new double[] { -5, -1, 0, 1, 5 });
+        doTestGeometry(line, GeoTestUtils.geoShapeValue(new Point(0, 3)));
+        doTestGeometry(pointsFromLine(line), GeoTestUtils.geoShapeValue(new Point(0, 1)));
+    }
+
+    public void testOffVerticalLineString() throws Exception {
+        // Even a very small x-range results in reasonable sorting for the label position
+        Line line = new Line(new double[] { -0.0005, -0.0001, 0, 0.0001, 0.0005 }, new double[] { -5, -1, 0, 1, 5 });
+        doTestGeometry(line, GeoTestUtils.geoShapeValue(new Point(-0.00005, -0.5)));
+        doTestGeometry(pointsFromLine(line), GeoTestUtils.geoShapeValue(new Point(0, 0)));
     }
 
     private void doTestGeometry(Geometry geometry) throws IOException {
+        doTestGeometry(geometry, null, false);
+    }
+
+    private void doTestGeometry(Geometry geometry, GeoShapeValues.GeoShapeValue expectedLabelPosition) throws IOException {
+        doTestGeometry(geometry, expectedLabelPosition, true);
+    }
+
+    private void doTestGeometry(Geometry geometry, GeoShapeValues.GeoShapeValue expectedLabelPosition, boolean fallbackToCentroid)
+        throws IOException {
         client().prepareIndex("test")
             .setId("1")
             .setSource(
@@ -170,6 +261,8 @@ public class GeoShapeScriptDocValuesIT extends ESSingleNodeTestCase {
             .addScriptField("lon", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "lon", Collections.emptyMap()))
             .addScriptField("height", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "height", Collections.emptyMap()))
             .addScriptField("width", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "width", Collections.emptyMap()))
+            .addScriptField("label_lat", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "label_lat", Collections.emptyMap()))
+            .addScriptField("label_lon", new Script(ScriptType.INLINE, CustomScriptPlugin.NAME, "label_lon", Collections.emptyMap()))
             .get();
         assertSearchResponse(searchResponse);
         Map<String, DocumentField> fields = searchResponse.getHits().getHits()[0].getFields();
@@ -177,6 +270,30 @@ public class GeoShapeScriptDocValuesIT extends ESSingleNodeTestCase {
         assertThat(fields.get("lon").getValue(), equalTo(value.lon()));
         assertThat(fields.get("height").getValue(), equalTo(value.boundingBox().maxY() - value.boundingBox().minY()));
         assertThat(fields.get("width").getValue(), equalTo(value.boundingBox().maxX() - value.boundingBox().minX()));
+
+        // Check label position is in the geometry, but with a tolerance constructed as a circle of 1m radius to handle quantization
+        Point labelPosition = new Point(fields.get("label_lon").getValue(), fields.get("label_lat").getValue());
+        Circle tolerance = new Circle(labelPosition.getX(), labelPosition.getY(), 1);
+        assertTrue("Expect label position " + labelPosition + " to intersect geometry " + geometry, value.intersects(tolerance));
+
+        // Check that the label position is the expected one, or the centroid in certain polygon cases
+        if (expectedLabelPosition != null) {
+            doTestLabelPosition(fields, expectedLabelPosition);
+        } else if (fallbackToCentroid && value.dimensionalShapeType() == DimensionalShapeType.POLYGON) {
+            // Use the centroid for all polygons, unless overwritten for specific cases
+            doTestLabelPosition(fields, GeoTestUtils.geoShapeValue(new Point(value.lon(), value.lat())));
+        }
+    }
+
+    private void doTestLabelPosition(Map<String, DocumentField> fields, GeoShapeValues.GeoShapeValue expectedLabelPosition)
+        throws IOException {
+        assertEquals("Unexpected latitude for label position,", expectedLabelPosition.lat(), fields.get("label_lat").getValue(), 0.0000001);
+        assertEquals(
+            "Unexpected longitude for label position,",
+            expectedLabelPosition.lon(),
+            fields.get("label_lon").getValue(),
+            0.0000001
+        );
     }
 
     public void testNullShape() throws Exception {

+ 38 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/GeoShapeValues.java

@@ -7,13 +7,19 @@
 
 package org.elasticsearch.xpack.spatial.index.fielddata;
 
+import org.apache.lucene.document.ShapeField;
+import org.apache.lucene.geo.LatLonGeometry;
 import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.common.geo.GeoPoint;
 import org.elasticsearch.common.geo.Orientation;
+import org.elasticsearch.common.geo.ShapeRelation;
 import org.elasticsearch.geometry.Geometry;
+import org.elasticsearch.geometry.Point;
 import org.elasticsearch.geometry.Rectangle;
 import org.elasticsearch.geometry.utils.GeographyValidator;
 import org.elasticsearch.geometry.utils.WellKnownText;
 import org.elasticsearch.index.mapper.GeoShapeIndexer;
+import org.elasticsearch.index.mapper.GeoShapeQueryable;
 import org.elasticsearch.search.aggregations.support.ValuesSourceType;
 import org.elasticsearch.xcontent.ToXContentFragment;
 import org.elasticsearch.xcontent.XContentBuilder;
@@ -107,6 +113,20 @@ public abstract class GeoShapeValues {
             return boundingBox;
         }
 
+        /**
+         * Select a label position that is within the shape.
+         */
+        public GeoPoint labelPosition() throws IOException {
+            // For polygons we prefer to use the centroid, as long as it is within the polygon
+            if (reader.getDimensionalShapeType() == DimensionalShapeType.POLYGON && intersects(new Point(lon(), lat()))) {
+                return new GeoPoint(lat(), lon());
+            }
+            // For all other cases, use the first triangle (or line or point) in the tree which will always intersect the shape
+            LabelPositionVisitor visitor = new LabelPositionVisitor(CoordinateEncoder.GEO);
+            reader.visit(visitor);
+            return visitor.labelPosition();
+        }
+
         public GeoRelation relate(Rectangle rectangle) throws IOException {
             int minX = CoordinateEncoder.GEO.encodeX(rectangle.getMinX());
             int maxX = CoordinateEncoder.GEO.encodeX(rectangle.getMaxX());
@@ -117,6 +137,24 @@ public abstract class GeoShapeValues {
             return tile2DVisitor.relation();
         }
 
+        /**
+         * Determine if the current shape value intersects the specified geometry.
+         * Note that the intersection must be true in quantized space, so it is possible that
+         * points on the edges of geometries will return false due to quantization shifting them off the geometry.
+         * To deal with this, one option is to pass in a circle around the point with a 1m radius
+         * which is enough to cover the resolution of the quantization.
+         */
+        public boolean intersects(Geometry geometry) throws IOException {
+            LatLonGeometry[] latLonGeometries = GeoShapeQueryable.toQuantizeLuceneGeometry(geometry, ShapeRelation.INTERSECTS);
+            Component2DVisitor visitor = Component2DVisitor.getVisitor(
+                LatLonGeometry.create(latLonGeometries),
+                ShapeField.QueryRelation.INTERSECTS,
+                CoordinateEncoder.GEO
+            );
+            reader.visit(visitor);
+            return visitor.matches();
+        }
+
         public DimensionalShapeType dimensionalShapeType() {
             return reader.getDimensionalShapeType();
         }

+ 93 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/LabelPositionVisitor.java

@@ -0,0 +1,93 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.spatial.index.fielddata;
+
+import org.elasticsearch.common.geo.GeoPoint;
+
+/**
+ * Get the first node of the tree and provide a point in that gemetry (point, line or triangle)
+ * as a suggested label position likely to be somewhere in the middle of the entire geometry.
+ *
+ * TODO: We could instead choose the point closer to the centroid which improves unbalanced trees
+ */
+public class LabelPositionVisitor implements TriangleTreeReader.Visitor {
+
+    private GeoPoint labelPosition;
+    private final CoordinateEncoder encoder;
+
+    public LabelPositionVisitor(CoordinateEncoder encoder) {
+        this.encoder = encoder;
+    }
+
+    @Override
+    public void visitPoint(int x, int y) {
+        double lon = encoder.decodeX(x);
+        double lat = encoder.decodeY(y);
+        // System.out.println("Got point: (" + lon + "," + lat + ")");
+        assert labelPosition == null;
+        labelPosition = new GeoPoint(lat, lon);
+    }
+
+    @Override
+    public void visitLine(int aX, int aY, int bX, int bY, byte metadata) {
+        double aLon = encoder.decodeX(aX);
+        double aLat = encoder.decodeY(aY);
+        double bLon = encoder.decodeX(bX);
+        double bLat = encoder.decodeY(bY);
+        // System.out.println("Got line: (" + aLon + "," + aLat + ")-(" + bLon + "," + bLat + ")");
+        assert labelPosition == null;
+        labelPosition = new GeoPoint((aLat + bLat) / 2.0, (aLon + bLon) / 2.0);
+    }
+
+    @Override
+    public void visitTriangle(int aX, int aY, int bX, int bY, int cX, int cY, byte metadata) {
+        double aLon = encoder.decodeX(aX);
+        double aLat = encoder.decodeY(aY);
+        double bLon = encoder.decodeX(bX);
+        double bLat = encoder.decodeY(bY);
+        double cLon = encoder.decodeX(cX);
+        double cLat = encoder.decodeY(cY);
+        // System.out.println("Got triangle: (" + aLon + "," + aLat + ")-(" + bLon + "," + bLat + ")-(" + cLon + "," + cLat + ")");
+        assert labelPosition == null;
+        labelPosition = new GeoPoint((aLat + bLat + cLat) / 3.0, (aLon + bLon + cLon) / 3.0);
+    }
+
+    @Override
+    public boolean push() {
+        // Don't traverse deeper once we found a result
+        return labelPosition == null;
+    }
+
+    @Override
+    public boolean pushX(int minX) {
+        // Don't traverse deeper once we found a result
+        return labelPosition == null;
+    }
+
+    @Override
+    public boolean pushY(int minY) {
+        // Don't traverse deeper once we found a result
+        return labelPosition == null;
+    }
+
+    @Override
+    public boolean push(int maxX, int maxY) {
+        // Don't traverse deeper once we found a result
+        return labelPosition == null;
+    }
+
+    @Override
+    public boolean push(int minX, int minY, int maxX, int maxY) {
+        // Always start the traversal
+        return true;
+    }
+
+    public GeoPoint labelPosition() {
+        return labelPosition;
+    }
+}

+ 5 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/plain/AbstractAtomicGeoShapeShapeFieldData.java

@@ -99,6 +99,11 @@ public abstract class AbstractAtomicGeoShapeShapeFieldData implements LeafGeoSha
             return gsSupplier.getInternal(0) == null ? null : gsSupplier.getInternalBoundingBox();
         }
 
+        @Override
+        public GeoPoint getLabelPosition() {
+            return gsSupplier.getInternal(0) == null ? null : gsSupplier.getInternalLabelPosition();
+        }
+
         @Override
         public GeoShapeValues.GeoShapeValue get(int index) {
             return gsSupplier.getInternal(0);

+ 10 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/mapper/GeoShapeWithDocValuesFieldMapper.java

@@ -50,6 +50,7 @@ import org.elasticsearch.xpack.spatial.index.fielddata.plain.AbstractLatLonShape
 import org.elasticsearch.xpack.spatial.search.aggregations.support.GeoShapeValuesSourceType;
 
 import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.util.Arrays;
 import java.util.Iterator;
 import java.util.List;
@@ -401,6 +402,15 @@ public class GeoShapeWithDocValuesFieldMapper extends AbstractShapeGeometryField
             return boundingBox;
         }
 
+        @Override
+        public GeoPoint getInternalLabelPosition() {
+            try {
+                return value.labelPosition();
+            } catch (IOException e) {
+                throw new UncheckedIOException("Failed to parse geo shape label position: " + e.getMessage(), e);
+            }
+        }
+
         @Override
         public String getName() {
             return name;

+ 156 - 2
x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/index/fielddata/GeometryDocValueTests.java

@@ -7,18 +7,34 @@
 
 package org.elasticsearch.xpack.spatial.index.fielddata;
 
+import org.elasticsearch.common.geo.GeoPoint;
 import org.elasticsearch.common.geo.GeometryNormalizer;
 import org.elasticsearch.common.geo.Orientation;
+import org.elasticsearch.geometry.Circle;
 import org.elasticsearch.geometry.Geometry;
 import org.elasticsearch.geometry.GeometryCollection;
+import org.elasticsearch.geometry.LinearRing;
+import org.elasticsearch.geometry.MultiPolygon;
+import org.elasticsearch.geometry.Point;
+import org.elasticsearch.geometry.Polygon;
 import org.elasticsearch.geometry.Rectangle;
 import org.elasticsearch.geometry.ShapeType;
+import org.elasticsearch.geometry.utils.StandardValidator;
+import org.elasticsearch.geometry.utils.WellKnownText;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.xpack.spatial.util.GeoTestUtils;
+import org.hamcrest.BaseMatcher;
+import org.hamcrest.Description;
 
+import java.io.BufferedReader;
 import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.text.ParseException;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.zip.GZIPInputStream;
 
 import static org.elasticsearch.geo.GeometryTestUtils.randomLine;
 import static org.elasticsearch.geo.GeometryTestUtils.randomMultiLine;
@@ -27,6 +43,9 @@ import static org.elasticsearch.geo.GeometryTestUtils.randomMultiPolygon;
 import static org.elasticsearch.geo.GeometryTestUtils.randomPoint;
 import static org.elasticsearch.geo.GeometryTestUtils.randomPolygon;
 import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.lessThan;
+import static org.hamcrest.Matchers.oneOf;
 
 public class GeometryDocValueTests extends ESTestCase {
 
@@ -101,16 +120,151 @@ public class GeometryDocValueTests extends ESTestCase {
             int maxX = randomIntBetween(1, 40);
             int minY = randomIntBetween(-40, -1);
             int maxY = randomIntBetween(1, 40);
-            Geometry rectangle = new Rectangle(minX, maxX, maxY, minY);
+            Rectangle rectangle = new Rectangle(minX, maxX, maxY, minY);
             GeometryDocValueReader reader = GeoTestUtils.geometryDocValueReader(rectangle, CoordinateEncoder.GEO);
 
             Extent expectedExtent = getExtentFromBox(minX, minY, maxX, maxY);
-            assertThat(expectedExtent, equalTo(reader.getExtent()));
+            assertThat("Rectangle extent", reader.getExtent(), equalTo(expectedExtent));
             // centroid is calculated using original double values but then loses precision as it is serialized as an integer
             int encodedCentroidX = CoordinateEncoder.GEO.encodeX(((double) minX + maxX) / 2);
             int encodedCentroidY = CoordinateEncoder.GEO.encodeY(((double) minY + maxY) / 2);
             assertEquals(encodedCentroidX, reader.getCentroidX());
             assertEquals(encodedCentroidY, reader.getCentroidY());
+
+            // Label position is the centroid if within the polygon
+            GeoShapeValues.GeoShapeValue shapeValue = GeoTestUtils.geoShapeValue(rectangle);
+            GeoPoint labelPosition = shapeValue.labelPosition();
+            double labelLon = ((double) minX + maxX) / 2;
+            double labelLat = ((double) minY + maxY) / 2;
+            assertEquals(labelLon, labelPosition.lon(), 0.0000001);
+            assertEquals(labelLat, labelPosition.lat(), 0.0000001);
+        }
+    }
+
+    public void testNonCentroidPolygon() throws IOException {
+        final Rectangle r1 = new Rectangle(-10, -5, 10, -10);
+        final Rectangle r2 = new Rectangle(5, 10, 10, -10);
+        MultiPolygon geometry = new MultiPolygon(List.of(toPolygon(r1), toPolygon(r2)));
+        GeometryDocValueReader reader = GeoTestUtils.geometryDocValueReader(geometry, CoordinateEncoder.GEO);
+
+        // Centroid is at the origin
+        int encodedCentroidX = CoordinateEncoder.GEO.encodeX(0);
+        int encodedCentroidY = CoordinateEncoder.GEO.encodeY(0);
+        assertEquals(encodedCentroidX, reader.getCentroidX());
+        assertEquals(encodedCentroidY, reader.getCentroidY());
+
+        // Label position is calculated as the first triangle
+        GeoShapeValues.GeoShapeValue shapeValue = GeoTestUtils.geoShapeValue(geometry);
+        GeoPoint labelPosition = shapeValue.labelPosition();
+        assertThat(
+            "Expect label position to match one of eight triangles in the two rectangles",
+            labelPosition,
+            isRectangleLabelPosition(r1, r2)
+        );
+    }
+
+    public void testAntarcticaLabelPosition() throws Exception {
+        Geometry geometry = loadResourceAsGeometry("Antarctica.wkt.gz");
+        GeometryDocValueReader reader = GeoTestUtils.geometryDocValueReader(geometry, CoordinateEncoder.GEO);
+
+        // Centroid is near the South Pole
+        int encodedLatThreshold = CoordinateEncoder.GEO.encodeY(-80);
+        assertThat(
+            "Centroid should be near the South Pole, or further South than -80 degrees",
+            reader.getCentroidY(),
+            lessThan(encodedLatThreshold)
+        );
+
+        // Label position is the centroid if within the polygon
+        GeoShapeValues.GeoShapeValue shapeValue = GeoTestUtils.geoShapeValue(geometry);
+        GeoPoint labelPosition = shapeValue.labelPosition();
+        double centroidX = CoordinateEncoder.GEO.decodeX(reader.getCentroidX());
+        double centroidY = CoordinateEncoder.GEO.decodeY(reader.getCentroidY());
+        assertEquals(centroidX, labelPosition.lon(), 0.0000001);
+        assertEquals(centroidY, labelPosition.lat(), 0.0000001);
+        Circle tolerance = new Circle(centroidX, centroidY, 1);
+        assertTrue("Expect label position to be within the geometry", shapeValue.intersects(tolerance));
+    }
+
+    public void testFranceLabelPosition() throws Exception {
+        Geometry geometry = loadResourceAsGeometry("France.wkt.gz");
+        GeometryDocValueReader reader = GeoTestUtils.geometryDocValueReader(geometry, CoordinateEncoder.GEO);
+
+        // Label position is the centroid if within the polygon
+        GeoShapeValues.GeoShapeValue shapeValue = GeoTestUtils.geoShapeValue(geometry);
+        GeoPoint labelPosition = shapeValue.labelPosition();
+        double centroidX = CoordinateEncoder.GEO.decodeX(reader.getCentroidX());
+        double centroidY = CoordinateEncoder.GEO.decodeY(reader.getCentroidY());
+        assertEquals(centroidX, labelPosition.lon(), 0.0000001);
+        assertEquals(centroidY, labelPosition.lat(), 0.0000001);
+        Circle tolerance = new Circle(centroidX, centroidY, 1);
+        assertTrue("Expect label position to be within the geometry", shapeValue.intersects(tolerance));
+    }
+
+    private Geometry loadResourceAsGeometry(String filename) throws IOException, ParseException {
+        GZIPInputStream is = new GZIPInputStream(getClass().getResourceAsStream(filename));
+        final BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8));
+        final Geometry geometry = WellKnownText.fromWKT(StandardValidator.instance(true), true, reader.readLine());
+        return geometry;
+    }
+
+    private Polygon toPolygon(Rectangle r) {
+        return new Polygon(
+            new LinearRing(
+                new double[] { r.getMinX(), r.getMaxX(), r.getMaxX(), r.getMinX(), r.getMinX() },
+                new double[] { r.getMinY(), r.getMinY(), r.getMaxY(), r.getMaxY(), r.getMinY() }
+            )
+        );
+    }
+
+    private static RectangleLabelPosition isRectangleLabelPosition(Rectangle... rectangles) {
+        return new RectangleLabelPosition(rectangles);
+    }
+
+    private static class RectangleLabelPosition extends BaseMatcher<GeoPoint> {
+        private final Point[] encodedPositions;
+
+        private RectangleLabelPosition(Rectangle... rectangles) {
+            encodedPositions = new Point[rectangles.length * 4];
+            for (int i = 0; i < rectangles.length; i++) {
+                Rectangle rectangle = rectangles[i];
+                GeoPoint a = new GeoPoint(rectangle.getMinY(), rectangle.getMinX());
+                GeoPoint b = new GeoPoint(rectangle.getMinY(), rectangle.getMaxX());
+                GeoPoint c = new GeoPoint(rectangle.getMaxY(), rectangle.getMaxX());
+                GeoPoint d = new GeoPoint(rectangle.getMaxY(), rectangle.getMinX());
+                encodedPositions[i * 4 + 0] = average(a, b, c);
+                encodedPositions[i * 4 + 1] = average(b, c, d);
+                encodedPositions[i * 4 + 2] = average(c, d, a);
+                encodedPositions[i * 4 + 3] = average(d, a, b);
+            }
+        }
+
+        private Point average(GeoPoint... points) {
+            double lon = 0;
+            double lat = 0;
+            for (GeoPoint point : points) {
+                lon += point.lon();
+                lat += point.lat();
+            }
+            int x = CoordinateEncoder.GEO.encodeX(lon / points.length);
+            int y = CoordinateEncoder.GEO.encodeY(lat / points.length);
+            return new Point(x, y);
+        }
+
+        @Override
+        public boolean matches(Object actual) {
+            if (actual instanceof GeoPoint) {
+                GeoPoint point = (GeoPoint) actual;
+                int x = CoordinateEncoder.GEO.encodeX(point.lon());
+                int y = CoordinateEncoder.GEO.encodeY(point.lat());
+                return is(oneOf(encodedPositions)).matches(new Point(x, y));
+            }
+            return false;
+        }
+
+        @Override
+        public void describeTo(Description description) {
+            description.appendText("is(oneOf(" + Arrays.toString(encodedPositions) + ")");
         }
     }
 

BIN
x-pack/plugin/spatial/src/test/resources/org/elasticsearch/xpack/spatial/index/fielddata/Antarctica.wkt.gz


BIN
x-pack/plugin/spatial/src/test/resources/org/elasticsearch/xpack/spatial/index/fielddata/France.wkt.gz


+ 540 - 0
x-pack/plugin/spatial/src/test/resources/org/elasticsearch/xpack/spatial/index/fielddata/LICENSE.txt

@@ -0,0 +1,540 @@
+## ODC Open Database License (ODbL)
+
+### Preamble
+
+The Open Database License (ODbL) is a license agreement intended to
+allow users to freely share, modify, and use this Database while
+maintaining this same freedom for others. Many databases are covered by
+copyright, and therefore this document licenses these rights. Some
+jurisdictions, mainly in the European Union, have specific rights that
+cover databases, and so the ODbL addresses these rights, too. Finally,
+the ODbL is also an agreement in contract for users of this Database to
+act in certain ways in return for accessing this Database.
+
+Databases can contain a wide variety of types of content (images,
+audiovisual material, and sounds all in the same database, for example),
+and so the ODbL only governs the rights over the Database, and not the
+contents of the Database individually. Licensors should use the ODbL
+together with another license for the contents, if the contents have a
+single set of rights that uniformly covers all of the contents. If the
+contents have multiple sets of different rights, Licensors should
+describe what rights govern what contents together in the individual
+record or in some other way that clarifies what rights apply.
+
+Sometimes the contents of a database, or the database itself, can be
+covered by other rights not addressed here (such as private contracts,
+trade mark over the name, or privacy rights / data protection rights
+over information in the contents), and so you are advised that you may
+have to consult other documents or clear other rights before doing
+activities not covered by this License.
+
+------
+
+The Licensor (as defined below)
+
+and
+
+You (as defined below)
+
+agree as follows:
+
+### 1.0 Definitions of Capitalised Words
+
+"Collective Database" - Means this Database in unmodified form as part
+of a collection of independent databases in themselves that together are
+assembled into a collective whole. A work that constitutes a Collective
+Database will not be considered a Derivative Database.
+
+"Convey" - As a verb, means Using the Database, a Derivative Database,
+or the Database as part of a Collective Database in any way that enables
+a Person to make or receive copies of the Database or a Derivative
+Database.  Conveying does not include interaction with a user through a
+computer network, or creating and Using a Produced Work, where no
+transfer of a copy of the Database or a Derivative Database occurs.
+"Contents" - The contents of this Database, which includes the
+information, independent works, or other material collected into the
+Database. For example, the contents of the Database could be factual
+data or works such as images, audiovisual material, text, or sounds.
+
+"Database" - A collection of material (the Contents) arranged in a
+systematic or methodical way and individually accessible by electronic
+or other means offered under the terms of this License.
+
+"Database Directive" - Means Directive 96/9/EC of the European
+Parliament and of the Council of 11 March 1996 on the legal protection
+of databases, as amended or succeeded.
+
+"Database Right" - Means rights resulting from the Chapter III ("sui
+generis") rights in the Database Directive (as amended and as transposed
+by member states), which includes the Extraction and Re-utilisation of
+the whole or a Substantial part of the Contents, as well as any similar
+rights available in the relevant jurisdiction under Section 10.4.
+
+"Derivative Database" - Means a database based upon the Database, and
+includes any translation, adaptation, arrangement, modification, or any
+other alteration of the Database or of a Substantial part of the
+Contents. This includes, but is not limited to, Extracting or
+Re-utilising the whole or a Substantial part of the Contents in a new
+Database.
+
+"Extraction" - Means the permanent or temporary transfer of all or a
+Substantial part of the Contents to another medium by any means or in
+any form.
+
+"License" - Means this license agreement and is both a license of rights
+such as copyright and Database Rights and an agreement in contract.
+
+"Licensor" - Means the Person that offers the Database under the terms
+of this License.
+
+"Person" - Means a natural or legal person or a body of persons
+corporate or incorporate.
+
+"Produced Work" -  a work (such as an image, audiovisual material, text,
+or sounds) resulting from using the whole or a Substantial part of the
+Contents (via a search or other query) from this Database, a Derivative
+Database, or this Database as part of a Collective Database.
+
+"Publicly" - means to Persons other than You or under Your control by
+either more than 50% ownership or by the power to direct their
+activities (such as contracting with an independent consultant).
+
+"Re-utilisation" - means any form of making available to the public all
+or a Substantial part of the Contents by the distribution of copies, by
+renting, by online or other forms of transmission.
+
+"Substantial" - Means substantial in terms of quantity or quality or a
+combination of both. The repeated and systematic Extraction or
+Re-utilisation of insubstantial parts of the Contents may amount to the
+Extraction or Re-utilisation of a Substantial part of the Contents.
+
+"Use" - As a verb, means doing any act that is restricted by copyright
+or Database Rights whether in the original medium or any other; and
+includes without limitation distributing, copying, publicly performing,
+publicly displaying, and preparing derivative works of the Database, as
+well as modifying the Database as may be technically necessary to use it
+in a different mode or format.
+
+"You" - Means a Person exercising rights under this License who has not
+previously violated the terms of this License with respect to the
+Database, or who has received express permission from the Licensor to
+exercise rights under this License despite a previous violation.
+
+Words in the singular include the plural and vice versa.
+
+### 2.0 What this License covers
+
+2.1. Legal effect of this document. This License is:
+
+  a. A license of applicable copyright and neighbouring rights;
+
+  b. A license of the Database Right; and
+
+  c. An agreement in contract between You and the Licensor.
+
+2.2 Legal rights covered. This License covers the legal rights in the
+Database, including:
+
+  a. Copyright. Any copyright or neighbouring rights in the Database.
+  The copyright licensed includes any individual elements of the
+  Database, but does not cover the copyright over the Contents
+  independent of this Database. See Section 2.4 for details. Copyright
+  law varies between jurisdictions, but is likely to cover: the Database
+  model or schema, which is the structure, arrangement, and organisation
+  of the Database, and can also include the Database tables and table
+  indexes; the data entry and output sheets; and the Field names of
+  Contents stored in the Database;
+
+  b. Database Rights. Database Rights only extend to the Extraction and
+  Re-utilisation of the whole or a Substantial part of the Contents.
+  Database Rights can apply even when there is no copyright over the
+  Database. Database Rights can also apply when the Contents are removed
+  from the Database and are selected and arranged in a way that would
+  not infringe any applicable copyright; and
+
+  c. Contract. This is an agreement between You and the Licensor for
+  access to the Database. In return you agree to certain conditions of
+  use on this access as outlined in this License.
+
+2.3 Rights not covered.
+
+  a. This License does not apply to computer programs used in the making
+  or operation of the Database;
+
+  b. This License does not cover any patents over the Contents or the
+  Database; and
+
+  c. This License does not cover any trademarks associated with the
+  Database.
+
+2.4 Relationship to Contents in the Database. The individual items of
+the Contents contained in this Database may be covered by other rights,
+including copyright, patent, data protection, privacy, or personality
+rights, and this License does not cover any rights (other than Database
+Rights or in contract) in individual Contents contained in the Database.
+For example, if used on a Database of images (the Contents), this
+License would not apply to copyright over individual images, which could
+have their own separate licenses, or one single license covering all of
+the rights over the images.
+
+### 3.0 Rights granted
+
+3.1 Subject to the terms and conditions of this License, the Licensor
+grants to You a worldwide, royalty-free, non-exclusive, terminable (but
+only under Section 9) license to Use the Database for the duration of
+any applicable copyright and Database Rights. These rights explicitly
+include commercial use, and do not exclude any field of endeavour. To
+the extent possible in the relevant jurisdiction, these rights may be
+exercised in all media and formats whether now known or created in the
+future.
+
+The rights granted cover, for example:
+
+  a. Extraction and Re-utilisation of the whole or a Substantial part of
+  the Contents;
+
+  b. Creation of Derivative Databases;
+
+  c. Creation of Collective Databases;
+
+  d. Creation of temporary or permanent reproductions by any means and
+  in any form, in whole or in part, including of any Derivative
+  Databases or as a part of Collective Databases; and
+
+  e. Distribution, communication, display, lending, making available, or
+  performance to the public by any means and in any form, in whole or in
+  part, including of any Derivative Database or as a part of Collective
+  Databases.
+
+3.2 Compulsory license schemes. For the avoidance of doubt:
+
+  a. Non-waivable compulsory license schemes. In those jurisdictions in
+  which the right to collect royalties through any statutory or
+  compulsory licensing scheme cannot be waived, the Licensor reserves
+  the exclusive right to collect such royalties for any exercise by You
+  of the rights granted under this License;
+
+  b. Waivable compulsory license schemes. In those jurisdictions in
+  which the right to collect royalties through any statutory or
+  compulsory licensing scheme can be waived, the Licensor waives the
+  exclusive right to collect such royalties for any exercise by You of
+  the rights granted under this License; and,
+
+  c. Voluntary license schemes. The Licensor waives the right to collect
+  royalties, whether individually or, in the event that the Licensor is
+  a member of a collecting society that administers voluntary licensing
+  schemes, via that society, from any exercise by You of the rights
+  granted under this License.
+
+3.3 The right to release the Database under different terms, or to stop
+distributing or making available the Database, is reserved. Note that
+this Database may be multiple-licensed, and so You may have the choice
+of using alternative licenses for this Database. Subject to Section
+10.4, all other rights not expressly granted by Licensor are reserved.
+
+### 4.0 Conditions of Use
+
+4.1 The rights granted in Section 3 above are expressly made subject to
+Your complying with the following conditions of use. These are important
+conditions of this License, and if You fail to follow them, You will be
+in material breach of its terms.
+
+4.2 Notices. If You Publicly Convey this Database, any Derivative
+Database, or the Database as part of a Collective Database, then You
+must:
+
+  a. Do so only under the terms of this License or another license
+  permitted under Section 4.4;
+
+  b. Include a copy of this License (or, as applicable, a license
+  permitted under Section 4.4) or its Uniform Resource Identifier (URI)
+  with the Database or Derivative Database, including both in the
+  Database or Derivative Database and in any relevant documentation; and
+
+  c. Keep intact any copyright or Database Right notices and notices
+  that refer to this License.
+
+  d. If it is not possible to put the required notices in a particular
+  file due to its structure, then You must include the notices in a
+  location (such as a relevant directory) where users would be likely to
+  look for it.
+
+4.3 Notice for using output (Contents). Creating and Using a Produced
+Work does not require the notice in Section 4.2. However, if you
+Publicly Use a Produced Work, You must include a notice associated with
+the Produced Work reasonably calculated to make any Person that uses,
+views, accesses, interacts with, or is otherwise exposed to the Produced
+Work aware that Content was obtained from the Database, Derivative
+Database, or the Database as part of a Collective Database, and that it
+is available under this License.
+
+  a. Example notice. The following text will satisfy notice under
+  Section 4.3:
+
+        Contains information from DATABASE NAME, which is made available
+        here under the Open Database License (ODbL).
+
+DATABASE NAME should be replaced with the name of the Database and a
+hyperlink to the URI of the Database. "Open Database License" should
+contain a hyperlink to the URI of the text of this License. If
+hyperlinks are not possible, You should include the plain text of the
+required URI's with the above notice.
+
+4.4 Share alike.
+
+  a. Any Derivative Database that You Publicly Use must be only under
+  the terms of:
+
+    i. This License;
+
+    ii. A later version of this License similar in spirit to this
+      License; or
+
+    iii. A compatible license.
+
+  If You license the Derivative Database under one of the licenses
+  mentioned in (iii), You must comply with the terms of that license.
+
+  b. For the avoidance of doubt, Extraction or Re-utilisation of the
+  whole or a Substantial part of the Contents into a new database is a
+  Derivative Database and must comply with Section 4.4.
+
+  c. Derivative Databases and Produced Works.  A Derivative Database is
+  Publicly Used and so must comply with Section 4.4. if a Produced Work
+  created from the Derivative Database is Publicly Used.
+
+  d. Share Alike and additional Contents. For the avoidance of doubt,
+  You must not add Contents to Derivative Databases under Section 4.4 a
+  that are incompatible with the rights granted under this License.
+
+  e. Compatible licenses. Licensors may authorise a proxy to determine
+  compatible licenses under Section 4.4 a iii. If they do so, the
+  authorised proxy's public statement of acceptance of a compatible
+  license grants You permission to use the compatible license.
+
+
+4.5 Limits of Share Alike.  The requirements of Section 4.4 do not apply
+in the following:
+
+  a. For the avoidance of doubt, You are not required to license
+  Collective Databases under this License if You incorporate this
+  Database or a Derivative Database in the collection, but this License
+  still applies to this Database or a Derivative Database as a part of
+  the Collective Database;
+
+  b. Using this Database, a Derivative Database, or this Database as
+  part of a Collective Database to create a Produced Work does not
+  create a Derivative Database for purposes of  Section 4.4; and
+
+  c. Use of a Derivative Database internally within an organisation is
+  not to the public and therefore does not fall under the requirements
+  of Section 4.4.
+
+4.6 Access to Derivative Databases. If You Publicly Use a Derivative
+Database or a Produced Work from a Derivative Database, You must also
+offer to recipients of the Derivative Database or Produced Work a copy
+in a machine readable form of:
+
+  a. The entire Derivative Database; or
+
+  b. A file containing all of the alterations made to the Database or
+  the method of making the alterations to the Database (such as an
+  algorithm), including any additional Contents, that make up all the
+  differences between the Database and the Derivative Database.
+
+The Derivative Database (under a.) or alteration file (under b.) must be
+available at no more than a reasonable production cost for physical
+distributions and free of charge if distributed over the internet.
+
+4.7 Technological measures and additional terms
+
+  a. This License does not allow You to impose (except subject to
+  Section 4.7 b.)  any terms or any technological measures on the
+  Database, a Derivative Database, or the whole or a Substantial part of
+  the Contents that alter or restrict the terms of this License, or any
+  rights granted under it, or have the effect or intent of restricting
+  the ability of any person to exercise those rights.
+
+  b. Parallel distribution. You may impose terms or technological
+  measures on the Database, a Derivative Database, or the whole or a
+  Substantial part of the Contents (a "Restricted Database") in
+  contravention of Section 4.74 a. only if You also make a copy of the
+  Database or a Derivative Database available to the recipient of the
+  Restricted Database:
+
+    i. That is available without additional fee;
+
+    ii. That is available in a medium that does not alter or restrict
+    the terms of this License, or any rights granted under it, or have
+    the effect or intent of restricting the ability of any person to
+    exercise those rights (an "Unrestricted Database"); and
+
+    iii. The Unrestricted Database is at least as accessible to the
+    recipient as a practical matter as the Restricted Database.
+
+  c. For the avoidance of doubt, You may place this Database or a
+  Derivative Database in an authenticated environment, behind a
+  password, or within a similar access control scheme provided that You
+  do not alter or restrict the terms of this License or any rights
+  granted under it or have the effect or intent of restricting the
+  ability of any person to exercise those rights.
+
+4.8 Licensing of others. You may not sublicense the Database. Each time
+You communicate the Database, the whole or Substantial part of the
+Contents, or any Derivative Database to anyone else in any way, the
+Licensor offers to the recipient a license to the Database on the same
+terms and conditions as this License. You are not responsible for
+enforcing compliance by third parties with this License, but You may
+enforce any rights that You have over a Derivative Database. You are
+solely responsible for any modifications of a Derivative Database made
+by You or another Person at Your direction. You may not impose any
+further restrictions on the exercise of the rights granted or affirmed
+under this License.
+
+### 5.0 Moral rights
+
+5.1 Moral rights. This section covers moral rights, including any rights
+to be identified as the author of the Database or to object to treatment
+that would otherwise prejudice the author's honour and reputation, or
+any other derogatory treatment:
+
+  a. For jurisdictions allowing waiver of moral rights, Licensor waives
+  all moral rights that Licensor may have in the Database to the fullest
+  extent possible by the law of the relevant jurisdiction under Section
+  10.4;
+
+  b. If waiver of moral rights under Section 5.1 a in the relevant
+  jurisdiction is not possible, Licensor agrees not to assert any moral
+  rights over the Database and waives all claims in moral rights to the
+  fullest extent possible by the law of the relevant jurisdiction under
+  Section 10.4; and
+
+  c. For jurisdictions not allowing waiver or an agreement not to assert
+  moral rights under Section 5.1 a and b, the author may retain their
+  moral rights over certain aspects of the Database.
+
+Please note that some jurisdictions do not allow for the waiver of moral
+rights, and so moral rights may still subsist over the Database in some
+jurisdictions.
+
+### 6.0 Fair dealing, Database exceptions, and other rights not affected
+
+6.1 This License does not affect any rights that You or anyone else may
+independently have under any applicable law to make any use of this
+Database, including without limitation:
+
+  a. Exceptions to the Database Right including: Extraction of Contents
+  from non-electronic Databases for private purposes, Extraction for
+  purposes of illustration for teaching or scientific research, and
+  Extraction or Re-utilisation for public security or an administrative
+  or judicial procedure.
+
+  b. Fair dealing, fair use, or any other legally recognised limitation
+  or exception to infringement of copyright or other applicable laws.
+
+6.2 This License does not affect any rights of lawful users to Extract
+and Re-utilise insubstantial parts of the Contents, evaluated
+quantitatively or qualitatively, for any purposes whatsoever, including
+creating a Derivative Database (subject to other rights over the
+Contents, see Section 2.4). The repeated and systematic Extraction or
+Re-utilisation of insubstantial parts of the Contents may however amount
+to the Extraction or Re-utilisation of a Substantial part of the
+Contents.
+
+### 7.0 Warranties and Disclaimer
+
+7.1 The Database is licensed by the Licensor "as is" and without any
+warranty of any kind, either express, implied, or arising by statute,
+custom, course of dealing, or trade usage. Licensor specifically
+disclaims any and all implied warranties or conditions of title,
+non-infringement, accuracy or completeness, the presence or absence of
+errors, fitness for a particular purpose, merchantability, or otherwise.
+Some jurisdictions do not allow the exclusion of implied warranties, so
+this exclusion may not apply to You.
+
+### 8.0 Limitation of liability
+
+8.1 Subject to any liability that may not be excluded or limited by law,
+the Licensor is not liable for, and expressly excludes, all liability
+for loss or damage however and whenever caused to anyone by any use
+under this License, whether by You or by anyone else, and whether caused
+by any fault on the part of the Licensor or not. This exclusion of
+liability includes, but is not limited to, any special, incidental,
+consequential, punitive, or exemplary damages such as loss of revenue,
+data, anticipated profits, and lost business. This exclusion applies
+even if the Licensor has been advised of the possibility of such
+damages.
+
+8.2 If liability may not be excluded by law, it is limited to actual and
+direct financial loss to the extent it is caused by proved negligence on
+the part of the Licensor.
+
+### 9.0 Termination of Your rights under this License
+
+9.1 Any breach by You of the terms and conditions of this License
+automatically terminates this License with immediate effect and without
+notice to You. For the avoidance of doubt, Persons who have received the
+Database, the whole or a Substantial part of the Contents, Derivative
+Databases, or the Database as part of a Collective Database from You
+under this License will not have their licenses terminated provided
+their use is in full compliance with this License or a license granted
+under Section 4.8 of this License.  Sections 1, 2, 7, 8, 9 and 10 will
+survive any termination of this License.
+
+9.2 If You are not in breach of the terms of this License, the Licensor
+will not terminate Your rights under it.
+
+9.3 Unless terminated under Section 9.1, this License is granted to You
+for the duration of applicable rights in the Database.
+
+9.4 Reinstatement of rights. If you cease any breach of the terms and
+conditions of this License, then your full rights under this License
+will be reinstated:
+
+  a. Provisionally and subject to permanent termination until the 60th
+  day after cessation of breach;
+
+  b. Permanently on the 60th day after cessation of breach unless
+  otherwise reasonably notified by the Licensor; or
+
+  c.  Permanently if reasonably notified by the Licensor of the
+  violation, this is the first time You have received notice of
+  violation of this License from  the Licensor, and You cure the
+  violation prior to 30 days after your receipt of the notice.
+
+Persons subject to permanent termination of rights are not eligible to
+be a recipient and receive a license under Section 4.8.
+
+9.5 Notwithstanding the above, Licensor reserves the right to release
+the Database under different license terms or to stop distributing or
+making available the Database. Releasing the Database under different
+license terms or stopping the distribution of the Database will not
+withdraw this License (or any other license that has been, or is
+required to be, granted under the terms of this License), and this
+License will continue in full force and effect unless terminated as
+stated above.
+
+### 10.0 General
+
+10.1 If any provision of this License is held to be invalid or
+unenforceable, that must not affect the validity or enforceability of
+the remainder of the terms and conditions of this License and each
+remaining provision of this License shall be valid and enforced to the
+fullest extent permitted by law.
+
+10.2 This License is the entire agreement between the parties with
+respect to the rights granted here over the Database. It replaces any
+earlier understandings, agreements or representations with respect to
+the Database.
+
+10.3 If You are in breach of the terms of this License, You will not be
+entitled to rely on the terms of this License or to complain of any
+breach by the Licensor.
+
+10.4 Choice of law. This License takes effect in and will be governed by
+the laws of the relevant jurisdiction in which the License terms are
+sought to be enforced. If the standard suite of rights granted under
+applicable copyright law and Database Rights in the relevant
+jurisdiction includes additional rights not granted under this License,
+these additional rights are granted in this License in order to meet the
+terms of this License.

+ 2 - 0
x-pack/plugin/spatial/src/test/resources/org/elasticsearch/xpack/spatial/index/fielddata/NOTICE.txt

@@ -0,0 +1,2 @@
+The Antarctica.wkt.gz and France.wkt.gz files come from the OpenStreetMap project (© OpenStreetMap contributors),
+which is made available here under the Open Database License (ODbL).

+ 13 - 0
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/spatial/70_script_doc_values.yml

@@ -47,6 +47,19 @@ setup:
   - match: { hits.hits.0.fields.bbox.0.bottom_right.lat: 59.94124996941537 }
   - match: { hits.hits.0.fields.bbox.0.bottom_right.lon: 24.048749981448054 }
 
+---
+"label position":
+  - do:
+      search:
+        rest_total_hits_as_int: true
+        body:
+          script_fields:
+            label_position:
+              script:
+                source: "doc['geo_shape'].getLabelPosition()"
+  - match: { hits.hits.0.fields.label_position.0.lat: 59.942043484188616 }
+  - match: { hits.hits.0.fields.label_position.0.lon: 24.047588920220733 }
+
 ---
 "bounding box points":
   - do: