diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5eecee --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target +.idea +*.iml +*.jar +*.class diff --git a/README.md b/README.md index 5c56261..a6dc113 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,96 @@ gorgeo ====== + +Provides a facet for grouping documents containing geolocation data based on +the geohash cells. It groups the geographical points by using +[geohash](http://en.wikipedia.org/wiki/Geohash) cells, according to the +level provided by user. + +### Installation + +TODO + +### Example usage + +Query example: + +```json + "geohashcell": { + "field": "location", + "level" : 3, + "top_left": "11.21,51.33", + "bottom_right": "-20.21,80.65" +} +``` + + + +Result example: + +```json + "facets": { + "f": { + "_type": "Z2VvaGFzaGNlbGw=", + "field": "location", + "top_left": "[11.21, 51.33]", + "bottom_right": "[-20.21, 80.65]", + "level": 4, + "base_map_level": 1, + "counts": { + "[8.876953125, 76.46484375]": "1", + "[6.240234375, 79.98046875]": "1", + "[6.943359375, 79.98046875]": "13", + "[9.755859375, 77.87109375]": "2", + "[2.197265625, 72.94921875]": "1", + "[9.931640625, 77.51953125]": "3", + ... + } + } +``` + +### Parameters + +| Parameter name | Optional | Default value | Description +| ---------------- | :--------: | :---------------: | ------------- +| __field__ | _optional_ | "location" | Name of the document field containing geographical location. +| __top_left__ | _optional_ | "90,-180" | Geographical coordinates (lattitude, longitude) of the top left point of the current map viewport. +| __bottom_right__ | _optional_ | "-90,180" | Geographical coordinates (lattitude, longitude) of the bottom right point of the current map viewport. +| __level__ | _optional_ | 3 | The grouping level (see [Algorithm](#Algorithm)). + +_Note_: the map viewport defaults to a full map. + +### Results + + * __field__ - equal to the __field__ parameter + * __top_left__ - equal to the __top_left__ parameter + * __bottom_right__ - equal to the __bottom_right__ parameter + * __level__ - actual grouping level (see [Algorithm](#Algorithm)) + * __base_map_level__ - base grouping level for the current map viewport + * __counts__ - grouping results, contain cell center and corresponding + document count + +### Algorithm + +The facet counts the documents based on geohash prefixes. For more information +on how geohashing works, see the following links: + + * [Wikipedia on Geohash](http://en.wikipedia.org/wiki/Geohash) + * [Visualizing Geohash](http://www.bigdatamodeling.org/2013/01/intuitive-geohash.html) + +The algorithm groups documents based on geohash prefixes of the stored +geographical data. The __level__ parameter provided to the facet is used as +a lenght of the geohash prefix for grouping, i.e. increasing the level by one, +divides the map into 32 cells. + +Additionally the facet adapts to the current map viewport by comparing the +current viewport size with sizes of the geohash cells and calculates the +__base_map_level__. This level is than added to the level parameter and used +as a geohash prefix. The result is always in [1,6] range. + +### To do + * better parameter checking + * more tests + +Credits +======= +Thanks to [Mahesh Paolini-Subramanya](https://github.com/dieswaytoofast) for his help. diff --git a/assembly/release.xml b/assembly/release.xml new file mode 100644 index 0000000..87aff4a --- /dev/null +++ b/assembly/release.xml @@ -0,0 +1,29 @@ + + + bin + + zip + + false + + + false + / + false + true + + org.elasticsearch:elasticsearch + junit:junit + + + + + + ${project.build.directory}/ + / + + elasticsearch-${project.name}-${elasticsearch.version}.jar + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..382571b --- /dev/null +++ b/pom.xml @@ -0,0 +1,122 @@ + + 4.0.0 + org.elasticsearch.plugin.geohashcellfacet + GeoHashCellFacetPlugin + jar + 1.0 + GeoHashCellFacetPlugin + http://maven.apache.org + + + + + org.apache.maven.plugins + maven-jar-plugin + 2.3 + + + elasticsearch-${project.name}-${elasticsearch.version} + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 2.2 + + + copy-dependencies + package + + copy-dependencies + + + ${project.build.directory}/lib + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.8 + + true + + + ${project.build.directory}/classes/conf + + + ${project.build.directory}/lib + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 2.2.1 + + + elasticsearch-${project.name}-${elasticsearch.version} + + false + ${project.build.directory}/release/ + + + assembly/release.xml + + + + + generate-release-plugin + package + + single + + + + + + + + src/main/resources + true + + **/*.properties + + + + + + + 0.90.5 + + + + + junit + junit + 4.8.2 + + + org.elasticsearch + elasticsearch + ${elasticsearch.version} + + + + + sonatype + Sonatype Groups + https://oss.sonatype.org/content/groups/public/ + + + diff --git a/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCell.java b/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCell.java new file mode 100644 index 0000000..fd856d5 --- /dev/null +++ b/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCell.java @@ -0,0 +1,82 @@ +package org.elasticsearch.plugin.geohashcellfacet; + +import org.elasticsearch.common.geo.GeoHashUtils; +import org.elasticsearch.common.geo.GeoPoint; + +/** + * Represents a cell established by the geo hash prefix. + */ +public class GeoHashCell { + public final static int MIN_GEOHASH_LEVEL = 1; + public final static int MAX_GEOHASH_LEVEL = 6; + + private final String geoHashPrefix; + private final GeoPoint topLeft = new GeoPoint(); + private final GeoPoint bottomRight = new GeoPoint(); + + /** + * Creates a geo hash cell of the provided level which includes the given point. + * + * @param point Point which should be included in this cell. + * @param level Level for the cell, i.e. the length of the geo hash. + */ + public GeoHashCell(GeoPoint point, int level) { + this(GeoHashUtils.encode(point.lat(), point.lon()).substring(0, level)); + } + + /** + * Creates a geo hash cell instance based on the given geo hash prefix. + * @param geoHashPrefix Prefix for the geo hash cell. + */ + public GeoHashCell(String geoHashPrefix) { + if (geoHashPrefix == null || geoHashPrefix.isEmpty()) + throw new IllegalArgumentException("GeoHash value is required"); + this.geoHashPrefix = geoHashPrefix; + + GeoHashUtils.decodeCell(geoHashPrefix, topLeft, bottomRight); + } + + /** + * Gets the point which is a center of this geo hash cell. + * @return Center of this cell. + */ + public GeoPoint getCenter() { + return new GeoPoint( + (topLeft.lat() + bottomRight.lat()) / 2D, + (topLeft.lon() + bottomRight.lon()) / 2D + ); + } + + /** + * Compare if this object equals another object. + * @param o Object to compare. + * @return True if objects are equal, otherwise false. + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + GeoHashCell that = (GeoHashCell) o; + + return geoHashPrefix.equals(that.geoHashPrefix); + } + + /** + * Gets a hash code of this instance. + * @return Hash code. + */ + @Override + public int hashCode() { + return geoHashPrefix.hashCode(); + } + + /** + * Gets a string representation of this instance. + * @return String representation. + */ + @Override + public String toString() { + return getCenter().toString(); + } +} diff --git a/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCellFacet.java b/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCellFacet.java new file mode 100644 index 0000000..3170af6 --- /dev/null +++ b/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCellFacet.java @@ -0,0 +1,127 @@ +package org.elasticsearch.plugin.geohashcellfacet; + +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.collect.Maps; +import org.elasticsearch.common.io.stream.StreamInput; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentBuilderString; +import org.elasticsearch.search.facet.Facet; +import org.elasticsearch.search.facet.InternalFacet; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +/** + * An ElasticSearch facet which allows grouping geo points by cells established + * by geo hash. + * Example usage: + * "geohashcell": { + * "level" : 3, + * "top_left": "11.21,51.33", + * "bottom_right": "-20.21,80.65" + * } + */ +public class GeoHashCellFacet extends InternalFacet { + + public static final String TYPE = "geohashcell"; + private static final BytesReference STREAM_TYPE = new BytesArray(TYPE); + private final MapBox mapBox; + + private Map counts = Maps.newHashMap(); + private final String fieldName; + private final int userLevel; + + /** + * Creates the facet instance. + * @param facetName Name of the facet. + * @param counts Map of the group counts. + * @param fieldName Name of the field used for grouping. + * @param mapBox Current map viewport. + * @param userLevel Level for geo hash grouping. + */ + public GeoHashCellFacet(String facetName, Map counts, + String fieldName, MapBox mapBox, + int userLevel) { + super(facetName); + this.counts = counts; + this.fieldName = fieldName; + this.mapBox = mapBox; + this.userLevel = userLevel; + } + + private GeoHashCellFacet() { + this.fieldName = null; + this.mapBox = null; + this.userLevel = 0; + } + + + public static void registerStreams() { + Streams.registerStream(STREAM, STREAM_TYPE); + } + + static InternalFacet.Stream STREAM = new InternalFacet.Stream() { + @Override + public Facet readFacet(StreamInput in) throws IOException { + GeoHashCellFacet facet = new GeoHashCellFacet(); + facet.readFrom(in); + return facet; + } + }; + + @Override + public BytesReference streamType() { + return STREAM_TYPE; + } + + @Override + public Facet reduce(ReduceContext reduceContext) { + List facets = reduceContext.facets(); + GeoHashCellFacet geoHashCellFacet = (GeoHashCellFacet) facets.get(0); + + for (int i = 1; i < facets.size(); i++) { + Facet facet = facets.get(i); + + if (facet instanceof GeoHashCellFacet) { + GeoHashCellFacet hashCellFacet = (GeoHashCellFacet) facet; + + for (Map.Entry entry : hashCellFacet.counts.entrySet()) { + if (geoHashCellFacet.counts.containsKey(entry.getKey())) { + geoHashCellFacet.counts.get(entry.getKey()).addAndGet(entry.getValue().longValue()); + } + else { + geoHashCellFacet.counts.put(entry.getKey(), entry.getValue()); + } + } + } + } + + return geoHashCellFacet; + } + + @Override + public String getType() { + return TYPE; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(getName()); + builder.field(Fields._TYPE, STREAM_TYPE); + builder.field(GeoHashCellFacetParser.ParamName.FIELD, fieldName); + builder.field(GeoHashCellFacetParser.ParamName.TOP_LEFT, mapBox.getTopLeft()); + builder.field(GeoHashCellFacetParser.ParamName.BOTTOM_RIGHT, mapBox.getBottomRight()); + builder.field(GeoHashCellFacetParser.ParamName.LEVEL, mapBox.getLevel(userLevel)); + builder.field("base_map_level", mapBox.getBaseLevel()); + builder.field("counts", counts); + builder.endObject(); + return builder; + } + + static final class Fields { + static final XContentBuilderString _TYPE = new XContentBuilderString("_type"); + } +} diff --git a/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCellFacetExecutor.java b/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCellFacetExecutor.java new file mode 100644 index 0000000..e0bd828 --- /dev/null +++ b/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCellFacetExecutor.java @@ -0,0 +1,90 @@ +package org.elasticsearch.plugin.geohashcellfacet; + +import org.apache.lucene.index.AtomicReaderContext; +import org.elasticsearch.common.collect.Maps; +import org.elasticsearch.common.geo.GeoPoint; +import org.elasticsearch.index.fielddata.GeoPointValues; +import org.elasticsearch.index.fielddata.IndexGeoPointFieldData; +import org.elasticsearch.search.facet.FacetExecutor; +import org.elasticsearch.search.facet.InternalFacet; +import org.elasticsearch.search.internal.SearchContext; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +public class GeoHashCellFacetExecutor extends FacetExecutor { + private final String fieldName; + private final int userLevel; + private Map counts = Maps.newHashMap(); + private final IndexGeoPointFieldData indexFieldData; + private final MapBox mapBox; + + public GeoHashCellFacetExecutor(String fieldName, + SearchContext searchContext, + MapBox mapBox, + int userLevel) { + this.fieldName = fieldName; + this.mapBox = mapBox; + this.userLevel = userLevel; + this.indexFieldData = searchContext + .fieldData() + .getForField(searchContext.smartNameFieldMapper(fieldName)); + } + + @Override + public InternalFacet buildFacet(String facetName) { + return new GeoHashCellFacet( + facetName, counts, fieldName, + mapBox, userLevel); + } + + @Override + public Collector collector() { + return new Collector(); + } + + final class Collector extends FacetExecutor.Collector { + + protected GeoPointValues values; + + @Override + public void setNextReader(AtomicReaderContext context) + throws IOException { + values = indexFieldData.load(context).getGeoPointValues(); + } + + @Override + public void collect(int docId) throws IOException { + final GeoPointValues.Iter iterator = values.getIter(docId); + + if (iterator == null || !iterator.hasNext()) { + increment("_missing", 1L); + return; + } + + while (iterator.hasNext()) { + GeoPoint point = iterator.next(); + + if (!mapBox.includes(point)) + continue; + + GeoHashCell cell = new GeoHashCell(point, mapBox.getLevel(userLevel)); + increment(cell.toString(), 1L); + } + } + + @Override + public void postCollection() { + // do nothing + } + + private void increment(String name, Long value) { + if (counts.containsKey(name)) { + counts.get(name).addAndGet(value); + } else { + counts.put(name, new AtomicLong(value)); + } + } + } +} diff --git a/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCellFacetParser.java b/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCellFacetParser.java new file mode 100644 index 0000000..a753fb7 --- /dev/null +++ b/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCellFacetParser.java @@ -0,0 +1,91 @@ +package org.elasticsearch.plugin.geohashcellfacet; + + +import org.elasticsearch.common.component.AbstractComponent; +import org.elasticsearch.common.geo.GeoPoint; +import org.elasticsearch.common.inject.Inject; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.search.facet.FacetExecutor; +import org.elasticsearch.search.facet.FacetParser; +import org.elasticsearch.search.internal.SearchContext; + +import java.io.IOException; + +public class GeoHashCellFacetParser extends AbstractComponent implements FacetParser { + + static final class ParamName { + final static String FIELD = "field"; + final static String TOP_LEFT = "top_left"; + final static String BOTTOM_RIGHT = "bottom_right"; + final static String LEVEL = "level"; + } + + static final class Default { + final static String FIELD = "location"; + final static GeoPoint TOP_LEFT = new GeoPoint(90L, -180L); + final static GeoPoint BOTTOM_RIGHT = new GeoPoint(-90L, 180L); + final static int LEVEL = 3; + } + + @Inject + public GeoHashCellFacetParser(Settings settings) { + super(settings); + GeoHashCellFacet.registerStreams(); + } + + @Override + public String[] types() { + return new String[] {GeoHashCellFacet.TYPE}; + } + + @Override + public FacetExecutor.Mode defaultMainMode() { + return FacetExecutor.Mode.COLLECTOR; + } + + @Override + public FacetExecutor.Mode defaultGlobalMode() { + return FacetExecutor.Mode.COLLECTOR; + } + + @Override + public FacetExecutor parse(String facetName, XContentParser parser, + SearchContext searchContext) throws IOException { + String field = Default.FIELD; + GeoPoint topLeft = new GeoPoint( + Default.TOP_LEFT.lat(), Default.TOP_LEFT.lon()); + GeoPoint bottomRight = new GeoPoint( + Default.BOTTOM_RIGHT.lat(), Default.BOTTOM_RIGHT.lon()); + + int level = Default.LEVEL; + + String currentFieldName = null; + XContentParser.Token token; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } + else if (token.isValue()) { + if (currentFieldName.equals(ParamName.FIELD)) { + field = parser.text(); + } + else if (currentFieldName.equals(ParamName.TOP_LEFT)) { + GeoPoint.parse(parser, topLeft); + } + else if (currentFieldName.equals(ParamName.BOTTOM_RIGHT)) { + GeoPoint.parse(parser, bottomRight); + } + else if (currentFieldName.equals(ParamName.LEVEL)) { + level = parser.intValue(); + } + } + } + + MapBox mapBox = new MapBox(topLeft, bottomRight); + + return new GeoHashCellFacetExecutor(field, searchContext, + mapBox, level); + } +} diff --git a/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCellFacetPlugin.java b/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCellFacetPlugin.java new file mode 100644 index 0000000..91948b8 --- /dev/null +++ b/src/main/java/org/elasticsearch/plugin/geohashcellfacet/GeoHashCellFacetPlugin.java @@ -0,0 +1,40 @@ +package org.elasticsearch.plugin.geohashcellfacet; + + +import org.elasticsearch.plugins.AbstractPlugin; +import org.elasticsearch.search.facet.FacetModule; + +/** + * Plugin which extends ElasticSearch with a Geohash Cell facet functionality. + */ +public class GeoHashCellFacetPlugin extends AbstractPlugin { + /** + * Name of the plugin. + * + * @return Name of the plugin. + */ + @Override + public String name() { + return "geohashcell-facet"; + } + + /** + * Description of the plugin. + * + * @return Description of the plugin. + */ + @Override + public String description() { + return "Geohash cell facet support"; + } + + /** + * Hooks up to the FacetModule initialization and adds a new facet parser + * {@link GeoHashCellFacetParser} which enables using {@link GeoHashCellFacet}. + * + * @param facetModule {@link FacetModule} instance + */ + public void onModule(FacetModule facetModule) { + facetModule.addFacetProcessor(GeoHashCellFacetParser.class); + } +} diff --git a/src/main/java/org/elasticsearch/plugin/geohashcellfacet/MapBox.java b/src/main/java/org/elasticsearch/plugin/geohashcellfacet/MapBox.java new file mode 100644 index 0000000..a2d16de --- /dev/null +++ b/src/main/java/org/elasticsearch/plugin/geohashcellfacet/MapBox.java @@ -0,0 +1,113 @@ +package org.elasticsearch.plugin.geohashcellfacet; + + +import org.elasticsearch.common.geo.GeoDistance; +import org.elasticsearch.common.geo.GeoPoint; +import org.elasticsearch.common.geo.GeoUtils; +import org.elasticsearch.common.unit.DistanceUnit; + +/** + * A box representing the current map viewport. + */ +public class MapBox { + private final GeoPoint topLeft; + private final GeoPoint bottomRight; + private final double width; + private final double height; + private final int baseLevel; + + /** + * Creates the map box based on the provided coordinates. + * @param topLeft Top-left point of the map. + * @param bottomRight Bottom-right point of the map. + */ + public MapBox(GeoPoint topLeft, GeoPoint bottomRight) { + // TODO check if correct + this.topLeft = topLeft; + this.bottomRight = bottomRight; + + this.width = calculateWidth(); + this.height = calculateHeight(); + this.baseLevel = calculateBaseLevel(); + } + + /** + * Top-left point of the map. + * @return Top-left point of the map. + */ + public GeoPoint getTopLeft() { + return topLeft; + } + + /** + * Bottom-right point of the map. + * @return Bottom-right point of the map. + */ + public GeoPoint getBottomRight() { + return bottomRight; + } + + /** + * Checks if the given point is included inside this box. + * @param point Point to check. + * @return True if point is within boundaries of the box. + */ + public boolean includes(GeoPoint point) { + // TODO what about crossing date change line + + return topLeft.lat() >= point.lat() && + topLeft.lon() <= point.lon() && + point.lat() >= bottomRight.lat() && + point.lon() <= bottomRight.lon(); + } + + /** + * Gets the base grouping level (geohash length) for the current viewport. + * @return Base grouping level. + */ + public int getBaseLevel() { + return baseLevel; + } + + /** + * Gets the actual grouping level (geohash length), based on the base level + * for the current map box and the level provided by the user. + * @param userLevel Grouping level provided by the "level" parameter. + * @return Grouping level in [1,6] range. + */ + public int getLevel(int userLevel) { + return Math.max(GeoHashCell.MIN_GEOHASH_LEVEL, + Math.min(GeoHashCell.MAX_GEOHASH_LEVEL, baseLevel + userLevel)); + } + + private int calculateBaseLevel() { + + for (int level = 1; level <= GeoHashCell.MAX_GEOHASH_LEVEL; level++) { + double cellWidth = GeoUtils.geoHashCellWidth(level); + double cellHeight = GeoUtils.geoHashCellHeight(level); + + if (geoHashCellSmallerThanMapBox(cellWidth, cellHeight)) + return level - 1; + } + + return GeoHashCell.MAX_GEOHASH_LEVEL - 1; + } + + private boolean geoHashCellSmallerThanMapBox(double cellWidth, double cellHeight) { + return cellWidth < width && cellHeight < height; + } + + private double calculateWidth() { + return GeoDistance.PLANE.calculate( + topLeft.lat(), topLeft.lon(), + topLeft.lat(), bottomRight.lon(), + DistanceUnit.METERS); + } + + private double calculateHeight() { + return GeoDistance.PLANE.calculate( + topLeft.lat(), topLeft.lon(), + bottomRight.lat(), topLeft.lon(), + DistanceUnit.METERS); + } +} diff --git a/src/main/resources/es-plugin.properties b/src/main/resources/es-plugin.properties new file mode 100644 index 0000000..a8b0908 --- /dev/null +++ b/src/main/resources/es-plugin.properties @@ -0,0 +1 @@ +plugin=org.elasticsearch.plugin.geohashcellfacet.GeoHashCellFacetPlugin \ No newline at end of file diff --git a/src/test/java/org/elasticsearch/plugin/tests/MapBoxTests.java b/src/test/java/org/elasticsearch/plugin/tests/MapBoxTests.java new file mode 100644 index 0000000..925179d --- /dev/null +++ b/src/test/java/org/elasticsearch/plugin/tests/MapBoxTests.java @@ -0,0 +1,68 @@ +package org.elasticsearch.plugin.tests; + + +import org.elasticsearch.plugin.geohashcellfacet.MapBox; +import org.elasticsearch.common.geo.GeoPoint; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@RunWith(JUnit4.class) +public class MapBoxTests { + + @Test + public void mapBoxIncludesCorners() { + MapBox mapBox = new MapBox(new GeoPoint(0L, 0L), new GeoPoint(-10L, 10L)); + assertTrue(mapBox.includes(new GeoPoint(0L, 0L))); + assertTrue(mapBox.includes(new GeoPoint(0L, 10L))); + assertTrue(mapBox.includes(new GeoPoint(-10L, 0L))); + assertTrue(mapBox.includes(new GeoPoint(-10L, 10L))); + } + + @Test + public void mapBoxIncludesPointInside() { + MapBox mapBox = new MapBox(new GeoPoint(0L, 0L), new GeoPoint(-10L, 10L)); + assertTrue(mapBox.includes(new GeoPoint(-5L, 5L))); + } + + @Test + public void mapBoxIncludesBoundaryPoints() { + MapBox mapBox = new MapBox(new GeoPoint(0L, 0L), new GeoPoint(-10L, 10L)); + assertTrue(mapBox.includes(new GeoPoint(0L, 5L))); + assertTrue(mapBox.includes(new GeoPoint(-5L, 0L))); + assertTrue(mapBox.includes(new GeoPoint(-10L, 5L))); + } + + @Test + public void mapBoxDoesNotIncludePointOutside() { + MapBox mapBox = new MapBox(new GeoPoint(0L, 0L), new GeoPoint(-10L, 10L)); + assertFalse(mapBox.includes(new GeoPoint(-15L, 15L))); + assertFalse(mapBox.includes(new GeoPoint(0L, 15L))); + } + + @Test + public void mapBoxForFullMapHasBaseLevel0() { + MapBox mapBox = new MapBox(new GeoPoint(90L, -180L), new GeoPoint(-90L, 180L)); + assertEquals(0, mapBox.getBaseLevel()); + } + + @Test + public void mapBoxForOneGeoHashLevelDownHasBaseLevel1() { + MapBox mapBox = new MapBox( + new GeoPoint(90L/4L, -180L/8L), + new GeoPoint(-90L/4L, 180L/8L)); + assertEquals(1, mapBox.getBaseLevel()); + } + + @Test + public void mapBoxForTwoGeoHashLevelsDownHasBaseLevel2() { + MapBox mapBox = new MapBox( + new GeoPoint(90L/32L, -180L/32L), + new GeoPoint(-90L/32L, 180L/32L)); + assertEquals(2, mapBox.getBaseLevel()); + } +}