Skip to content

Commit

Permalink
Support geo label position as runtime field (#86154)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
craigtaverner authored May 10, 2022
1 parent 65d9098 commit 36fd640
Show file tree
Hide file tree
Showing 18 changed files with 1,093 additions and 15 deletions.
5 changes: 5 additions & 0 deletions docs/changelog/86154.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 86154
summary: Support geo label position as runtime field
area: Geo
type: enhancement
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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;
Expand All @@ -21,6 +22,8 @@
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;

Expand All @@ -36,6 +39,8 @@
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 {

Expand All @@ -54,6 +59,8 @@ protected Map<String, Function<Map<String, Object>, Object>> pluginScripts() {
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;
}

Expand Down Expand Up @@ -91,15 +98,29 @@ private double scriptLon(Map<String, Object> vars) {
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;
Expand Down Expand Up @@ -140,6 +161,8 @@ public void testRandomPoint() throws Exception {
.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);

Expand All @@ -151,6 +174,10 @@ public void testRandomPoint() throws Exception {
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 {
Expand Down Expand Up @@ -178,6 +205,8 @@ public void testRandomMultiPoint() throws Exception {
.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);

Expand All @@ -196,6 +225,11 @@ public void testRandomMultiPoint() throws Exception {
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 {
Expand All @@ -221,4 +255,29 @@ public void testNullPoint() throws Exception {
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) + ")");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ public Geometry(Supplier<T> supplier) {
/** 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();

Expand All @@ -247,6 +250,8 @@ public interface GeometrySupplier<T> extends Supplier<T> {
GeoPoint getInternalCentroid();

GeoBoundingBox getInternalBoundingBox();

GeoPoint getInternalLabelPosition();
}

public static class GeoPoints extends Geometry<GeoPoint> {
Expand Down Expand Up @@ -363,6 +368,11 @@ public double getMercatorHeight() {
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> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -82,12 +87,7 @@ private static double[] quantizeLons(double[] lons) {
* 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];
}
Expand Down Expand Up @@ -130,7 +130,7 @@ public Void visit(org.elasticsearch.geometry.Line line) {
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())));
}
Expand All @@ -139,7 +139,7 @@ public Void visit(org.elasticsearch.geometry.Line line) {

@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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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;

Expand All @@ -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;
Expand Down Expand Up @@ -71,11 +73,13 @@ private void setSingleValue() throws IOException {
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;
Expand All @@ -89,12 +93,22 @@ private void setMultiValue() throws IOException {
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) {
Expand All @@ -121,6 +135,11 @@ public GeoBoundingBox getInternalBoundingBox() {
return boundingBox;
}

@Override
public GeoPoint getInternalLabelPosition() {
return values[labelIndex];
}

@Override
public String getName() {
return name;
Expand Down
Loading

0 comments on commit 36fd640

Please sign in to comment.