Repository: gisaia/ARLAS-server (language: python)

Issue:
_(geo)aggregate :  Add geotile aggregation type
As a user of ARLAS-server `_aggregate` service

I want to be able to aggregate geo-points using a `map tile` grid *({zoom}/{x}/{y})* with a large precision range (larger than the geohash grid aggregation precision range)

in order to smooth the precision changes while zooming in (in a map client for example)

According to the documentation 
https://www.elastic.co/guide/en/elasticsearch/reference/7.11/search-aggregations-bucket-geotilegrid-aggregation.html

Technically this means :
- [x] new aggregation type **geotile**
  - datehistogram
  - histogram
  - term
  - geohash
  - **geotile**

- [x] new endpoint `@Path("{collection}/_geoaggregate/{z}/{x}/{y}")
   - so we need to transform {z}/{x}/{y} EPSG 3857 tiles to a bounding box as top_left and bottom_right. (same behaviour of the endpoint `@Path("{collection}/_geoaggregate/{geohash}")`)
- [x] For this new agg type, we need to pass a precision in the `agg` query param, in the `interval` attribute (default value `z+3` if not specified)
   - this precision corresponds to zoom level of the aggregation, that should be larger than or equal to {z} in the path param
   - the maximum difference between the precision and {z} in the path param is **6**
   - If we want a difference of 7 we need to split the  bbox resulting from the {z}/{x}/{y} tile into 4 similar bboxes the same way we did #656 
- [x] How to handle returned geometries by the ES aggregation ?
  - The response of ES is 
```
   "aggregations": {
    "large-grid": {
      "buckets": [
        {
          "key": "8/131/84",
          "doc_count": 3
        },
        {
          "key": "8/129/88",
          "doc_count": 2
        },
        {
          "key": "8/131/85",
          "doc_count": 1
        }
      ]
    }
    }
```
We need to transform the `"key"` to a geometry. The geometry to return is to be specified in `aggregated_geometries` subparam of the `agg` param.

For now `aggregated_geometries` have four possible values
- geohash
- geohash_center
- centroid
- bbox

- [x] First thing we need to do, is replace 
`geohash` => `tile`
`geohash_center` => `tile_center`
in order to be generic to the `**geotile**` and `**geohash**`

- [x] Update documentation
- [ ] Integration tests
_(geo)aggregate :  Add geotile aggregation type
As a user of ARLAS-server `_aggregate` service

I want to be able to aggregate geo-points using a `map tile` grid *({zoom}/{x}/{y})* with a large precision range (larger than the geohash grid aggregation precision range)

in order to smooth the precision changes while zooming in (in a map client for example)

According to the documentation 
https://www.elastic.co/guide/en/elasticsearch/reference/7.11/search-aggregations-bucket-geotilegrid-aggregation.html

Technically this means :
- [x] new aggregation type **geotile**
  - datehistogram
  - histogram
  - term
  - geohash
  - **geotile**

- [x] new endpoint `@Path("{collection}/_geoaggregate/{z}/{x}/{y}")
   - so we need to transform {z}/{x}/{y} EPSG 3857 tiles to a bounding box as top_left and bottom_right. (same behaviour of the endpoint `@Path("{collection}/_geoaggregate/{geohash}")`)
- [x] For this new agg type, we need to pass a precision in the `agg` query param, in the `interval` attribute (default value `z+3` if not specified)
   - this precision corresponds to zoom level of the aggregation, that should be larger than or equal to {z} in the path param
   - the maximum difference between the precision and {z} in the path param is **6**
   - If we want a difference of 7 we need to split the  bbox resulting from the {z}/{x}/{y} tile into 4 similar bboxes the same way we did #656 
- [x] How to handle returned geometries by the ES aggregation ?
  - The response of ES is 
```
   "aggregations": {
    "large-grid": {
      "buckets": [
        {
          "key": "8/131/84",
          "doc_count": 3
        },
        {
          "key": "8/129/88",
          "doc_count": 2
        },
        {
          "key": "8/131/85",
          "doc_count": 1
        }
      ]
    }
    }
```
We need to transform the `"key"` to a geometry. The geometry to return is to be specified in `aggregated_geometries` subparam of the `agg` param.

For now `aggregated_geometries` have four possible values
- geohash
- geohash_center
- centroid
- bbox

- [x] First thing we need to do, is replace 
`geohash` => `tile`
`geohash_center` => `tile_center`
in order to be generic to the `**geotile**` and `**geohash**`

- [x] Update documentation
- [ ] Integration tests


Discussion / hints:
New agg type "GEOTILES" to add.
New agg type "GEOTILES" to add.

Fix patch:
diff --git a/arlas-core/src/main/java/io/arlas/server/app/Documentation.java b/arlas-core/src/main/java/io/arlas/server/app/Documentation.java
index d63f34ee7..1397148a9 100644
--- a/arlas-core/src/main/java/io/arlas/server/app/Documentation.java
+++ b/arlas-core/src/main/java/io/arlas/server/app/Documentation.java
@@ -109,7 +109,7 @@ public class Documentation {
             "\n \n" +
             "   - **{type}:{field}** part is mandatory. " +
             "\n \n" +
-            "   - **interval** must be specified only when aggregation type is datehistogram, histogram and geohash." +
+            "   - **interval** must be specified only when aggregation type is datehistogram, histogram, geotile and geohash." +
             "\n \n" +
             "   - **format** is optional for datehistogram, and must not be specified for the other types." +
             "\n \n" +
@@ -121,13 +121,13 @@ public class Documentation {
             "\n \n" +
             "   - (**order**,**on**) couple is optional for all aggregation types." +
             "\n \n" +
-            "   - **size** is optional for term and geohash, and must not be specified for the other types." +
+            "   - **size** is optional for term and geohash/geotile, and must not be specified for the other types." +
             "\n \n" +
             "   - **include** is optional for term, and must not be specified for the other types." +
             "\n \n" +
             "- {type} possible values are : " +
             "\n \n" +
-            "       datehistogram, histogram, geohash and term. " +
+            "       datehistogram, histogram, geohash, geotile and term. " +
             "\n \n" +
             "- {interval} possible values depends on {type}. " +
             "\n \n" +
@@ -137,6 +137,8 @@ public class Documentation {
             "\n \n" +
             "       If {type} = geohash, then {interval} = {size}. It's an integer between 1 and 12. Lower the length, greater is the surface of aggregation. " +
             "\n \n" +
+            "       If {type} = geotile, then {interval} = {size}. It's an integer corresponding to zoom level of the aggregation, that should be larger than or equal to {z} in the path param, and no bigger than {z}+6. " +
+            "\n \n" +
             "       If {type} = term, then interval-{interval} is not needed. " +
             "\n \n" +
             "- format-{format} is the date format for key aggregation. The default value is yyyy-MM-dd-hh:mm:ss." +
@@ -164,19 +166,19 @@ public class Documentation {
             "\n \n" +
             "    > __**Syntax**__: `aggregated_geometries-{COMMA_SEPARATED_AGGREGATED_GEOMETRIES}`." +
             "\n \n" +
-            "    > __**Available aggregated geometries**__: `centroid, bbox, geohash, geohash_center`." +
+            "    > __**Available aggregated geometries**__: `centroid, bbox, tile, tile_center`." +
             "\n \n" +
             "       - **centroid**: returns the centroid of data inside the bucket." +
             "\n \n" +
             "       - **bbox**: returns the data extent (bbox) in each bucket." +
             "\n \n" +
-            "       - **geohash**: returns the 'geohash' extent of each bucket. This form is supported for **geohash** aggregation type only." +
+            "       - **tile**: returns the tile extent (zxy or geohash) of each bucket. This form is supported for **geohash** and **geotile** aggregation type only." +
             "\n \n" +
-            "       - **geohash_center**: returns the geohash center of each bucket. This form is supported for **geohash** aggregation type only." +
+            "       - **tile_center**: returns the tile center of each bucket. This form is supported for **geohash** and **geotile** aggregation type only." +
             "\n \n" +
             "    > __**Response**__: the aggregated geometries are returned in `geometries` list in the json response. Each object inside this list has : the reference to the aggregated form, the geojson geometry and an attribute `is_raw` set to false" +
             "\n \n" +
-            "    > __**Example**__: `aggregated_geometries-bbox,geohash`" +
+            "    > __**Example**__: `aggregated_geometries-bbox,tile`" +
             "\n \n" +
             "- **raw_geometries**" +
             "\n \n" +
@@ -213,6 +215,7 @@ public class Documentation {
     public static final String GEOAGGREGATION_OPERATION = "Aggregate the elements in the collection(s) as features, given the filters and the aggregation parameters.";
     public static final String SHAPEAGGREGATION_OPERATION = "Aggregate the elements in the collection(s) as features, given the filters and the aggregation parameters, and returns a shapefile of it.";
     public static final String GEOHASH_GEOAGGREGATION_OPERATION = "Aggregate the elements in the collection(s) and localized in the given geohash as features, given the filters and the aggregation parameters.";
+    public static final String GEOTILE_GEOAGGREGATION_OPERATION = "Aggregate the elements in the collection(s) and localized in the given tile as features, given the filters and the aggregation parameters.";
     public static final String GEOAGGREGATION_PARAM_AGG = "- The agg parameter should be given in the following formats:  " +
             "\n \n" +
             "       {type}:{field}:interval-{interval}:format-{format}:collect_field-{collect_field}:collect_fct-{function}:order-{order}:on-{on}:size-{size}:raw_geometries-{raw_geometries values}:aggregated_geometries-{aggregated_geometries values}:fetch_hits-{fetch_hits values}" +
@@ -221,7 +224,7 @@ public class Documentation {
             "\n \n" +
             "   - **{type}:{field}** part is mandatory. " +
             "\n \n" +
-            "   - **interval** must be specified only when aggregation type is datehistogram, histogram and geohash." +
+            "   - **interval** must be specified only when aggregation type is datehistogram, histogram, geotile and geohash." +
             "\n \n" +
             "   - **format** is optional for datehistogram, and must not be specified for the other types." +
             "\n \n" +
@@ -229,13 +232,13 @@ public class Documentation {
             "\n \n" +
             "   - (**order**,**on**) couple is optional for all aggregation types." +
             "\n \n" +
-            "   - **size** is optional for term and geohash, and must not be specified for the other types." +
+            "   - **size** is optional for term and geohash/geotile, and must not be specified for the other types." +
             "\n \n" +
             "   - **include** is optional for term, and must not be specified for the other types." +
             "\n \n" +
             "- {type} possible values are : " +
             "\n \n" +
-            "       geohash, datehistogram, histogram and term. geohash must be the main aggregation." +
+            "       geohash, geotile, datehistogram, histogram and term. geohash or geotile must be the main aggregation." +
             "\n \n" +
             "- {interval} possible values depends on {type}. " +
             "\n \n" +
@@ -245,6 +248,8 @@ public class Documentation {
             "\n \n" +
             "       If {type} = geohash, then {interval} = {size}. It's an integer between 1 and 12. Lower the length, greater is the surface of aggregation. " +
             "\n \n" +
+            "       If {type} = geotile, then {interval} = {size}. It's an integer corresponding to zoom level of the aggregation, that should be larger than or equal to {z} in the path param, and no bigger than {z}+6 (max 29). " +
+            "\n \n" +
             "       If {type} = term, then interval-{interval} is not needed. " +
             "\n \n" +
             "- format-{format} is the date format for key aggregation. The default value is yyyy-MM-dd-hh:mm:ss." +
@@ -274,15 +279,15 @@ public class Documentation {
             "\n \n" +
             "    > __**Syntax**__: `aggregated_geometries-{COMMA_SEPARATED_AGGREGATED_GEOMETRIES}`." +
             "\n \n" +
-            "    > __**Available aggregated geometries**__: `centroid, bbox, geohash, geohash_center`." +
+            "    > __**Available aggregated geometries**__: `centroid, bbox, tile, tile_center`." +
             "\n \n" +
             "       - **centroid**: returns the centroid of data inside the bucket." +
             "\n \n" +
             "       - **bbox**: returns the data extent (bbox) in each bucket." +
             "\n \n" +
-            "       - **geohash**: returns the 'geohash' extent of each bucket. This form is supported for **geohash** aggregation type only." +
+            "       - **tile**: returns the tile (zxy or geohash) extent of each bucket. This form is supported for **geohash** and **geotile** aggregation type only." +
             "\n \n" +
-            "       - **geohash_center**: returns the geohash center of each bucket. This form is supported for **geohash** aggregation type only." +
+            "       - **tile_center**: returns the tile center of each bucket. This form is supported for **geohash** and **geotile** aggregation type only." +
             "\n \n" +
             "    > __**Response**__: Each bucket of the aggregation will be represented with as many features (in a feature collection) as there are specified aggregated geometries. The properties of each feature has :" +
             "\n \n" +
@@ -326,7 +331,7 @@ public class Documentation {
             "\n \n" +
             "    > __**Example**__: `fetch_hits-3(-timestamp, geometry)`. Fetches the 3 last positions for each bucket." +
             "\n \n" +
-            "**agg** parameter is multiple. The first (main) aggregation must be geohash. Every agg parameter specified is a subaggregation of the previous one : order matters. " +
+            "**agg** parameter is multiple. The first (main) aggregation must be geohash or geotile. Every agg parameter specified is a subaggregation of the previous one : order matters. " +
             "\n \n" +
             "For more details, check https://github.com/gisaia/ARLAS-server/blob/master/docs/arlas-api-exploration.md ";
 
diff --git a/arlas-core/src/main/java/io/arlas/server/impl/elastic/core/ElasticFluidSearch.java b/arlas-core/src/main/java/io/arlas/server/impl/elastic/core/ElasticFluidSearch.java
index 2ac7c2825..657111a0d 100644
--- a/arlas-core/src/main/java/io/arlas/server/impl/elastic/core/ElasticFluidSearch.java
+++ b/arlas-core/src/main/java/io/arlas/server/impl/elastic/core/ElasticFluidSearch.java
@@ -27,11 +27,17 @@
 import io.arlas.server.managers.CollectionReferenceManager;
 import io.arlas.server.model.CollectionReference;
 import io.arlas.server.model.enumerations.*;
-import io.arlas.server.model.request.*;
+import io.arlas.server.model.request.Aggregation;
+import io.arlas.server.model.request.Expression;
+import io.arlas.server.model.request.Metric;
+import io.arlas.server.model.request.MultiValueFilter;
 import io.arlas.server.model.response.FieldType;
 import io.arlas.server.model.response.TimestampType;
 import io.arlas.server.services.FluidSearchService;
-import io.arlas.server.utils.*;
+import io.arlas.server.utils.CheckParams;
+import io.arlas.server.utils.GeoUtil;
+import io.arlas.server.utils.ParamsParser;
+import io.arlas.server.utils.StringUtil;
 import org.apache.commons.lang3.tuple.Pair;
 import org.elasticsearch.action.search.SearchRequest;
 import org.elasticsearch.action.search.SearchResponse;
@@ -61,6 +67,8 @@
 import java.io.IOException;
 import java.util.*;
 
+import static io.arlas.server.utils.CheckParams.GEO_AGGREGATION_TYPE_ENUMS;
+
 public class ElasticFluidSearch extends FluidSearchService {
     private static Logger LOGGER = LoggerFactory.getLogger(ElasticFluidSearch.class);
 
@@ -424,7 +432,9 @@ private AggregationBuilder aggregateRecursive(List<Aggregation> aggregations, Ag
         //check the agg syntax is correct
         Aggregation aggregationModel = aggregations.get(0);
         if (isGeoAggregate && counter == 0) {
-            if (aggregationModel.type != AggregationTypeEnum.geohash && aggregationModel.rawGeometries == null && aggregationModel.aggregatedGeometries == null) {
+            if (!GEO_AGGREGATION_TYPE_ENUMS.contains(aggregationModel.type)
+                    && aggregationModel.rawGeometries == null
+                    && aggregationModel.aggregatedGeometries == null) {
                 throw new NotAllowedException("'" + aggregationModel.type.name() +"' aggregation type is not allowed in _geoaggregate service if at least `aggregated_geometries` or `raw_geometries` parameters are not specified");
             }
         }
@@ -435,6 +445,9 @@ private AggregationBuilder aggregateRecursive(List<Aggregation> aggregations, Ag
             case geohash:
                 aggregationBuilder = buildGeohashAggregation(aggregationModel);
                 break;
+            case geotile:
+                aggregationBuilder = buildGeotileAggregation(aggregationModel);
+                break;
             case histogram:
                 aggregationBuilder = buildHistogramAggregation(aggregationModel);
                 break;
@@ -569,6 +582,20 @@ private GeoGridAggregationBuilder buildGeohashAggregation(Aggregation aggregatio
         return geoHashAggregationBuilder;
     }
 
+    // construct and returns the geotile aggregationModel builder
+    private GeoGridAggregationBuilder buildGeotileAggregation(Aggregation aggregationModel) throws ArlasException {
+        GeoGridAggregationBuilder geoTileAggregationBuilder = AggregationBuilders.geotileGrid(GEOTILE_AGG);
+        //get the precision
+        Integer precision = (Integer)aggregationModel.interval.value;
+        geoTileAggregationBuilder = geoTileAggregationBuilder.precision(precision);
+        //get the field, format, collect_field, collect_fct, order, on
+        geoTileAggregationBuilder = (GeoGridAggregationBuilder) setAggregationParameters(aggregationModel, geoTileAggregationBuilder);
+        geoTileAggregationBuilder = (GeoGridAggregationBuilder) setAggregatedGeometries(aggregationModel, geoTileAggregationBuilder);
+        geoTileAggregationBuilder = (GeoGridAggregationBuilder) setRawGeometries(aggregationModel, geoTileAggregationBuilder);
+        geoTileAggregationBuilder = (GeoGridAggregationBuilder) setHitsToFetch(aggregationModel, geoTileAggregationBuilder);
+        return geoTileAggregationBuilder;
+    }
+
     // construct and returns the histogram aggregationModel builder
     private HistogramAggregationBuilder buildHistogramAggregation(Aggregation aggregationModel) throws ArlasException {
         HistogramAggregationBuilder histogramAggregationBuilder = AggregationBuilders.histogram(HISTOGRAM_AGG);
@@ -680,7 +707,7 @@ else if (aggregationBuilder instanceof GeoGridAggregationBuilder)
 
     private ValuesSourceAggregationBuilder setAggregatedGeometries(Aggregation aggregationModel, ValuesSourceAggregationBuilder aggregationBuilder) throws ArlasException {
         if (aggregationModel.aggregatedGeometries != null) {
-            String aggregationGeoField = (aggregationModel.type.equals(AggregationTypeEnum.geohash) ? aggregationModel.field : collectionReference.params.centroidPath);
+            String aggregationGeoField = GEO_AGGREGATION_TYPE_ENUMS.contains(aggregationModel.type) ? aggregationModel.field : collectionReference.params.centroidPath;
             aggregationModel.aggregatedGeometries.forEach(ag -> {
                 ValuesSourceAggregationBuilder metricAggregation;
                 switch (ag) {
diff --git a/arlas-core/src/main/java/io/arlas/server/impl/elastic/services/ElasticExploreService.java b/arlas-core/src/main/java/io/arlas/server/impl/elastic/services/ElasticExploreService.java
index 555cce46e..50feab165 100644
--- a/arlas-core/src/main/java/io/arlas/server/impl/elastic/services/ElasticExploreService.java
+++ b/arlas-core/src/main/java/io/arlas/server/impl/elastic/services/ElasticExploreService.java
@@ -36,9 +36,7 @@
 import io.arlas.server.services.CollectionReferenceService;
 import io.arlas.server.services.ExploreService;
 import io.arlas.server.services.FluidSearchService;
-import io.arlas.server.utils.CheckParams;
-import io.arlas.server.utils.MapExplorer;
-import io.arlas.server.utils.UriInfoWrapper;
+import io.arlas.server.utils.*;
 import org.apache.commons.collections.CollectionUtils;
 import org.elasticsearch.action.search.SearchResponse;
 import org.elasticsearch.common.geo.GeoPoint;
@@ -58,6 +56,7 @@
 import java.util.*;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import static io.arlas.server.services.FluidSearchService.*;
 
@@ -397,21 +396,49 @@ private AggregationResponse formatAggregationResult(MultiBucketsAggregation aggr
                 GeoPoint geoPoint = getGeohashCentre(element.keyAsString.toString());
                 element.key = geoPoint;
                 if (!CollectionUtils.isEmpty(aggregatedGeometries)) {
-                    aggregatedGeometries.stream().filter(g -> g == AggregatedGeometryEnum.GEOHASHCENTER || g == AggregatedGeometryEnum.GEOHASH).forEach(g -> {
-                        ReturnedGeometry returnedGeometry = new ReturnedGeometry();
-                        returnedGeometry.reference = g.value();
-                        returnedGeometry.isRaw = false;
-                        if (g == AggregatedGeometryEnum.GEOHASH) {
-                            returnedGeometry.geometry = createPolygonFromRectangle(GeohashUtils.decodeBoundary(element.keyAsString.toString(), SpatialContext.GEO));
-                        } else {
-                            returnedGeometry.geometry = new Point(geoPoint.getLon(), geoPoint.getLat());
-                        }
-                        if (element.geometries == null) {
-                            element.geometries = new ArrayList<>();
-                        }
-                        element.geometries.add(returnedGeometry);
+                    aggregatedGeometries.stream()
+                            .filter(g -> g == AggregatedGeometryEnum.TILECENTER || g == AggregatedGeometryEnum.TILE)
+                            .forEach(g -> {
+                                ReturnedGeometry returnedGeometry = new ReturnedGeometry();
+                                returnedGeometry.reference = g.value();
+                                returnedGeometry.isRaw = false;
+                                if (g == AggregatedGeometryEnum.TILE) {
+                                    returnedGeometry.geometry = createPolygonFromRectangle(GeohashUtils.decodeBoundary(element.keyAsString.toString(), SpatialContext.GEO));
+                                } else {
+                                    returnedGeometry.geometry = new Point(geoPoint.getLon(), geoPoint.getLat());
+                                }
+                                if (element.geometries == null) {
+                                    element.geometries = new ArrayList<>();
+                                }
+                                element.geometries.add(returnedGeometry);
 
-                    });
+                            });
+                }
+            } else if (aggregationResponse.name.equals(GEOTILE_AGG)) {
+                List<Integer> zxy = Stream.of(element.keyAsString.toString().split("/"))
+                        .map (elem -> Integer.valueOf(elem))
+                        .collect(Collectors.toList());
+                BoundingBox tile = GeoTileUtil.getBoundingBox(zxy.get(1), zxy.get(2), zxy.get(0));
+                GeoPoint geoPoint = getTileCentre(tile);
+                element.key = geoPoint;
+                if (!CollectionUtils.isEmpty(aggregatedGeometries)) {
+                    aggregatedGeometries.stream()
+                            .filter(g -> g == AggregatedGeometryEnum.TILECENTER || g == AggregatedGeometryEnum.TILE)
+                            .forEach(g -> {
+                                ReturnedGeometry returnedGeometry = new ReturnedGeometry();
+                                returnedGeometry.reference = g.value();
+                                returnedGeometry.isRaw = false;
+                                if (g == AggregatedGeometryEnum.TILE) {
+                                    returnedGeometry.geometry = createBox(tile);
+                                } else {
+                                    returnedGeometry.geometry = new Point(geoPoint.getLon(), geoPoint.getLat());
+                                }
+                                if (element.geometries == null) {
+                                    element.geometries = new ArrayList<>();
+                                }
+                                element.geometries.add(returnedGeometry);
+
+                            });
                 }
             } else if(aggregationResponse.name.startsWith(DATEHISTOGRAM_AGG)){
                 element.key = ((ZonedDateTime)bucket.getKey()).withZoneSameInstant(ZoneOffset.UTC).toInstant().toEpochMilli();
@@ -435,7 +462,7 @@ private AggregationResponse formatAggregationResult(MultiBucketsAggregation aggr
                                 .map(Arrays::asList)
                                 .map(hitsList -> hitsList.stream().map(SearchHit::getSourceAsMap).collect(Collectors.toList()))
                                 .orElse(new ArrayList());
-                    } else if (Arrays.asList(DATEHISTOGRAM_AGG, HISTOGRAM_AGG, TERM_AGG, GEOHASH_AGG).contains(subAggregation.getName())) {
+                    } else if (Arrays.asList(DATEHISTOGRAM_AGG, HISTOGRAM_AGG, TERM_AGG, GEOHASH_AGG, GEOTILE_AGG).contains(subAggregation.getName())) {
                         subAggregationResponse = formatAggregationResult(((MultiBucketsAggregation) subAggregation), subAggregationResponse, collection, aggregationsRequest, aggTreeDepth+1);
                     } else if (isAggregatedGeometry(subAggregation.getName(), aggregatedGeometries)) {
                         subAggregationResponse = null;
@@ -565,6 +592,25 @@ private GeoPoint getGeohashCentre(String geohash) {
         return new GeoPoint(lat, lon);
     }
 
+    private GeoPoint getTileCentre(BoundingBox bbox) {
+        double lon = (bbox.getEast() + bbox.getWest()) / 2;
+        double lat = (bbox.getNorth() + bbox.getSouth()) / 2;
+
+        return new GeoPoint(lat, lon);
+    }
+
+    private Polygon createBox(BoundingBox bbox) {
+        Polygon box = new Polygon();
+        List<LngLatAlt> bounds = new ArrayList<>();
+        bounds.add(new LngLatAlt(bbox.getWest(), bbox.getNorth()));
+        bounds.add(new LngLatAlt(bbox.getEast(), bbox.getNorth()));
+        bounds.add(new LngLatAlt(bbox.getEast(), bbox.getSouth()));
+        bounds.add(new LngLatAlt(bbox.getWest(), bbox.getSouth()));
+        box.add(bounds);
+
+        return box;
+    }
+
     private Polygon createBox(GeoBounds subAggregation) {
         Polygon box = new Polygon();
         GeoPoint topLeft = subAggregation.topLeft();
diff --git a/arlas-core/src/main/java/io/arlas/server/model/enumerations/AggregatedGeometryEnum.java b/arlas-core/src/main/java/io/arlas/server/model/enumerations/AggregatedGeometryEnum.java
index b446b2059..199c86430 100644
--- a/arlas-core/src/main/java/io/arlas/server/model/enumerations/AggregatedGeometryEnum.java
+++ b/arlas-core/src/main/java/io/arlas/server/model/enumerations/AggregatedGeometryEnum.java
@@ -26,7 +26,7 @@
 import java.util.stream.Collectors;
 
 public enum AggregatedGeometryEnum {
-    BBOX("bbox"), CENTROID("centroid"), GEOHASH("geohash"), GEOHASHCENTER("geohash_center");
+    BBOX("bbox"), CENTROID("centroid"), TILE("tile"), TILECENTER("tile_center");
 
     private final String value;
     private static final String INVALID_AGGREGATED_GEOMETRY = "Invalid aggregated geometry `";
diff --git a/arlas-core/src/main/java/io/arlas/server/model/enumerations/AggregationTypeEnum.java b/arlas-core/src/main/java/io/arlas/server/model/enumerations/AggregationTypeEnum.java
index 89fb22d44..974bb3ee7 100644
--- a/arlas-core/src/main/java/io/arlas/server/model/enumerations/AggregationTypeEnum.java
+++ b/arlas-core/src/main/java/io/arlas/server/model/enumerations/AggregationTypeEnum.java
@@ -20,5 +20,5 @@
 package io.arlas.server.model.enumerations;
 
 public enum AggregationTypeEnum {
-    datehistogram, geohash, histogram, term;
+    datehistogram, geohash, geotile, histogram, term;
 }
\ No newline at end of file
diff --git a/arlas-core/src/main/java/io/arlas/server/services/FluidSearchService.java b/arlas-core/src/main/java/io/arlas/server/services/FluidSearchService.java
index a0519ab12..5d561337f 100644
--- a/arlas-core/src/main/java/io/arlas/server/services/FluidSearchService.java
+++ b/arlas-core/src/main/java/io/arlas/server/services/FluidSearchService.java
@@ -50,6 +50,7 @@ public abstract class FluidSearchService {
     public static final String HISTOGRAM_AGG = "Histogram aggregation";
     public static final String TERM_AGG = "Term aggregation";
     public static final String GEOHASH_AGG = "Geohash aggregation";
+    public static final String GEOTILE_AGG = "Geotile aggregation";
     public static final String FETCH_HITS_AGG = "fetched_hits";
     public static final String GEO_DISTANCE = "geodistance";
     public static final String NO_INCLUDE_TO_SPECIFY = "'include-' should not be specified for this aggregation";
@@ -63,7 +64,7 @@ public abstract class FluidSearchService {
     public static final String ORDER_PARAM_NOT_ALLOWED = "Order is not allowed for geohash aggregation.";
     public static final String ORDER_ON_RESULT_NOT_ALLOWED = "'on-result' sorts 'collect_field' and 'collect_fct' results. Please specify 'collect_field' and 'collect_fct'.";
     public static final String ORDER_ON_GEO_RESULT_NOT_ALLOWED = "Ordering on 'result' is not allowed for geo-box neither geo-centroid metric aggregation. ";
-    public static final String SIZE_NOT_IMPLEMENTED = "Size is not implemented for geohash.";
+    public static final String SIZE_NOT_IMPLEMENTED = "Size is not implemented for geohash/geotile.";
 
     public static final String FIELD_MIN_VALUE = "field_min_value";
     public static final String FIELD_MAX_VALUE = "field_max_value";
diff --git a/arlas-core/src/main/java/io/arlas/server/utils/CheckParams.java b/arlas-core/src/main/java/io/arlas/server/utils/CheckParams.java
index eece5de8f..d39b4fc69 100644
--- a/arlas-core/src/main/java/io/arlas/server/utils/CheckParams.java
+++ b/arlas-core/src/main/java/io/arlas/server/utils/CheckParams.java
@@ -67,15 +67,17 @@ public class CheckParams {
     private static final String INVALID_ON_VALUE = "Invalid 'on-' value : ";
     private static final String BAD_COLLECT_FCT_COLLECT_FIELD_NUMBERS = "'collect_field' and 'collect_fct' occurrences should be even.";
     private static final String DATE_NOW = "now";
-    private static final String AGGREGATED_GEOMETRY_NOT_SUPPORTED = "'geohash' & 'geohash_center' are only supported for geohash aggregation type.";
+    private static final String AGGREGATED_GEOMETRY_NOT_SUPPORTED = "'tile' & 'tile_center' are only supported for geohash and geotile aggregation type.";
 
     public static final String INTERVAL_NOT_SPECIFIED = "Interval parameter is not specified.";
     public static final String INTERVAL_VALUE_NOT_SPECIFIED = "Interval value is missing.";
     public static final String INTERVAL_UNIT_NOT_SPECIFIED = "Interval unit is missing.";
-    public static final String NO_INTERVAL_UNIT_FOR_GEOHASH_NOR_HISTOGRAM = "Interval unit must not be specified for geohash nor histogram aggregations.";
+    public static final String NO_INTERVAL_UNIT_FOR_GEOHASH_NOR_HISTOGRAM = "Interval unit must not be specified for geohash/geotile nor histogram aggregations.";
     public static final String NO_TERM_INTERVAL = "'Interval' should not be specified for term aggregation.";
     public static final String RAW_GEOMETRIES_NULL_OR_EMPTY = "'geometries' should not be null nor empty";
 
+    public static final List<AggregationTypeEnum> GEO_AGGREGATION_TYPE_ENUMS = Arrays.asList(AggregationTypeEnum.geohash, AggregationTypeEnum.geotile);
+
     public CheckParams() {
     }
 
@@ -220,18 +222,18 @@ public static void checkRawGeometriesParameter(Aggregation aggregationModel, Col
     public static void checkAggregatedGeometryParameter(Aggregation aggregationModel) throws ArlasException {
         if (aggregationModel.aggregatedGeometries != null) {
             List<AggregatedGeometryEnum> geometries = aggregationModel.aggregatedGeometries;
-            if (!geometries.isEmpty()) {
-                if ((geometries.contains(AggregatedGeometryEnum.GEOHASH) || geometries.contains(AggregatedGeometryEnum.GEOHASHCENTER)) && aggregationModel.type != AggregationTypeEnum.geohash) {
-                    throw new NotAllowedException(AGGREGATED_GEOMETRY_NOT_SUPPORTED);
-                }
-                if (aggregationModel.type == AggregationTypeEnum.geohash && aggregationModel.aggregatedGeometries.isEmpty() && aggregationModel.rawGeometries == null) {
-                    aggregationModel.aggregatedGeometries = new ArrayList<>();
-                    aggregationModel.aggregatedGeometries.add(AggregatedGeometryEnum.GEOHASHCENTER);
-                }
+            if ((geometries.contains(AggregatedGeometryEnum.TILE) || geometries.contains(AggregatedGeometryEnum.TILECENTER))
+                    && !GEO_AGGREGATION_TYPE_ENUMS.contains(aggregationModel.type)) {
+                throw new NotAllowedException(AGGREGATED_GEOMETRY_NOT_SUPPORTED);
             }
-        } else if (aggregationModel.type == AggregationTypeEnum.geohash && aggregationModel.rawGeometries == null) {
+            if (GEO_AGGREGATION_TYPE_ENUMS.contains(aggregationModel.type)
+                    && geometries.isEmpty() && aggregationModel.rawGeometries == null) {
+                aggregationModel.aggregatedGeometries = new ArrayList<>();
+                aggregationModel.aggregatedGeometries.add(AggregatedGeometryEnum.TILECENTER);
+            }
+        } else if (GEO_AGGREGATION_TYPE_ENUMS.contains(aggregationModel.type) && aggregationModel.rawGeometries == null) {
             aggregationModel.aggregatedGeometries = new ArrayList<>();
-            aggregationModel.aggregatedGeometries.add(AggregatedGeometryEnum.GEOHASHCENTER);
+            aggregationModel.aggregatedGeometries.add(AggregatedGeometryEnum.TILECENTER);
         }
     }
 
@@ -254,9 +256,8 @@ public static void checkAggregationIntervalParameter(Aggregation aggregationMode
                     Number intervalValue = 0;
                     switch (aggregationModel.type) {
                         case datehistogram:
-                            intervalValue = ParamsParser.tryParseInteger(interval.value.toString());
-                            break;
                         case geohash:
+                        case geotile:
                             intervalValue = ParamsParser.tryParseInteger(interval.value.toString());
                             break;
                         case histogram:
@@ -267,6 +268,10 @@ public static void checkAggregationIntervalParameter(Aggregation aggregationMode
                         switch (aggregationModel.type) {
                             case datehistogram:
                                 throw new InvalidParameterException("The datehistogram interval must be a positive integer.");
+                            case geotile:
+                                if (intervalValue.doubleValue() < 0) {
+                                    throw new InvalidParameterException("The geotile precision is not valid. It must be a positive integer.");
+                                }
                             case geohash:
                                 throw new InvalidParameterException("The geohash precision is not valid. It must be an integer between 1 and 12.");
                             case histogram:
@@ -275,6 +280,8 @@ public static void checkAggregationIntervalParameter(Aggregation aggregationMode
                     }
                     if (intervalValue != null && aggregationModel.type == AggregationTypeEnum.geohash && ParamsParser.tryParseInteger(interval.value.toString()) >= 13) {
                         throw new InvalidParameterException("The geohash precision is not valid. It must be an integer between 1 and 12.");
+                    } if (intervalValue != null && aggregationModel.type == AggregationTypeEnum.geotile && ParamsParser.tryParseInteger(interval.value.toString()) > 29) {
+                        throw new InvalidParameterException("The geotile precision is not valid. It must be an integer between 0 and 29.");
                     } else if (intervalValue != null) {
                         aggregationModel.interval.value = intervalValue;
                     }
diff --git a/arlas-core/src/main/java/io/arlas/server/utils/ParamsParser.java b/arlas-core/src/main/java/io/arlas/server/utils/ParamsParser.java
index 3552aebac..8210f7ebc 100644
--- a/arlas-core/src/main/java/io/arlas/server/utils/ParamsParser.java
+++ b/arlas-core/src/main/java/io/arlas/server/utils/ParamsParser.java
@@ -48,6 +48,8 @@
 
 import static io.arlas.server.services.FluidSearchService.*;
 
+import static io.arlas.server.utils.CheckParams.GEO_AGGREGATION_TYPE_ENUMS;
+
 public class ParamsParser {
     private static ObjectMapper objectMapper = new ObjectMapper();
     private static final String AGG_INTERVAL_PARAM = "interval-";
@@ -98,7 +100,7 @@ public static Aggregation getAggregationModel(CollectionReference collectionRefe
             if (parameter.contains(AGG_INTERVAL_PARAM)) {
                 if (aggregationModel.type.equals(AggregationTypeEnum.datehistogram)) {
                     aggregationModel.interval = getDatehistogramAggregationInterval(parameter.substring(AGG_INTERVAL_PARAM.length()));
-                } else if (aggregationModel.type.equals(AggregationTypeEnum.geohash)) {
+                } else if (GEO_AGGREGATION_TYPE_ENUMS.contains(aggregationModel.type)) {
                     aggregationModel.interval = getGeohashAggregationInterval(parameter.substring(AGG_INTERVAL_PARAM.length()));
                 } else if (aggregationModel.type.equals(AggregationTypeEnum.histogram)) {
                     aggregationModel.interval = getHistogramAggregationInterval(parameter.substring(AGG_INTERVAL_PARAM.length()));
diff --git a/arlas-rest/src/main/java/io/arlas/server/rest/explore/aggregate/GeoAggregateRESTService.java b/arlas-rest/src/main/java/io/arlas/server/rest/explore/aggregate/GeoAggregateRESTService.java
index e89cafb11..4a029354c 100644
--- a/arlas-rest/src/main/java/io/arlas/server/rest/explore/aggregate/GeoAggregateRESTService.java
+++ b/arlas-rest/src/main/java/io/arlas/server/rest/explore/aggregate/GeoAggregateRESTService.java
@@ -23,6 +23,7 @@
 import io.arlas.server.app.ArlasServerConfiguration;
 import io.arlas.server.app.Documentation;
 import io.arlas.server.exceptions.ArlasException;
+import io.arlas.server.exceptions.InvalidParameterException;
 import io.arlas.server.model.CollectionReference;
 import io.arlas.server.model.enumerations.AggregationGeometryEnum;
 import io.arlas.server.model.enumerations.AggregationTypeEnum;
@@ -309,7 +310,9 @@ public Response geohashgeoaggregate(
         AggregationTypeEnum aggType = null;
         for (BoundingBox b : bboxes) {
             Expression pwithinBbox = new Expression(collectionReference.params.centroidPath, OperatorEnum.within,
-                    b.getWest() + "," + b.getSouth() + "," + String.format("%.8f", b.getEast() - GEOHASH_EPSILON) + "," + String.format("%.8f", b.getNorth() - GEOHASH_EPSILON));
+                    b.getWest() + "," + b.getSouth() + ","
+                            + String.format(Locale.ROOT, "%.8f", b.getEast() - GEOHASH_EPSILON) + ","
+                            + String.format(Locale.ROOT,"%.8f", b.getNorth() - GEOHASH_EPSILON));
             MixedRequest request = getGeoaggregateRequest(collectionReference,
                     ParamsParser.getFilter(collectionReference, f, q, dateformat, b, pwithinBbox)
                     , partitionFilter, columnFilter, agg);
@@ -335,6 +338,128 @@ public Response geohashgeoaggregate(
 
     }
 
+    @Timed
+    @Path("{collection}/_geoaggregate/{z}/{x}/{y}")
+    @GET
+    @Produces(UTF8JSON)
+    @Consumes(UTF8JSON)
+    @ApiOperation(value = "GeoAggregate on a geotile", produces = UTF8JSON, notes = Documentation.GEOTILE_GEOAGGREGATION_OPERATION, consumes = UTF8JSON, response = FeatureCollection.class)
+    @ApiResponses(value = {@ApiResponse(code = 200, message = "Successful operation", response = FeatureCollection.class, responseContainer = "FeatureCollection"),
+            @ApiResponse(code = 500, message = "Arlas Server Error.", response = Error.class), @ApiResponse(code = 400, message = "Bad request.", response = Error.class),
+            @ApiResponse(code = 501, message = "Not implemented functionality.", response = Error.class)})
+    public Response geotilegeoaggregate(
+            // --------------------------------------------------------
+            // ----------------------- PATH ---------------------------
+            // --------------------------------------------------------
+            @ApiParam(name = "collection",
+                    value = "collection",
+                    required = true)
+            @PathParam(value = "collection") String collection,
+
+            @ApiParam(name = "z",
+                    value = "z",
+                    required = true)
+            @PathParam(value = "z") Integer z,
+            @ApiParam(name = "x",
+                    value = "x",
+                    required = true)
+            @PathParam(value = "x") Integer x,
+            @ApiParam(name = "y",
+                    value = "y",
+                    required = true)
+            @PathParam(value = "y") Integer y,
+
+            // --------------------------------------------------------
+            // ----------------------- AGGREGATION --------------------
+            // --------------------------------------------------------
+            @ApiParam(name = "agg",
+                    value = Documentation.GEOAGGREGATION_PARAM_AGG)
+            @QueryParam(value = "agg") List<String> agg,
+
+            // --------------------------------------------------------
+            // ----------------------- FILTER -------------------------
+            // --------------------------------------------------------
+            @ApiParam(name = "f",
+                    value = Documentation.FILTER_PARAM_F,
+                    allowMultiple = true)
+            @QueryParam(value = "f") List<String> f,
+
+            @ApiParam(name = "q", value = Documentation.FILTER_PARAM_Q,
+                    allowMultiple = true)
+            @QueryParam(value = "q") List<String> q,
+
+            @ApiParam(name = "dateformat",
+                    value = Documentation.FILTER_DATE_FORMAT)
+            @QueryParam(value = "dateformat") String dateformat,
+
+            @ApiParam(hidden = true)
+            @HeaderParam(value = "partition-filter") String partitionFilter,
+
+            @ApiParam(hidden = true)
+            @HeaderParam(value = "Column-Filter") Optional<String> columnFilter,
+
+            // --------------------------------------------------------
+            // ----------------------- FORM ---------------------------
+            // --------------------------------------------------------
+            @ApiParam(name = "pretty",
+                    value = Documentation.FORM_PRETTY,
+                    defaultValue = "false")
+            @QueryParam(value = "pretty") Boolean pretty,
+
+            @ApiParam(name = "flat",
+                    value = Documentation.FORM_FLAT,
+                    defaultValue = "false")
+            @QueryParam(value = "flat") Boolean flat,
+
+            // --------------------------------------------------------
+            // ----------------------- EXTRA --------------------------
+            // --------------------------------------------------------
+            @ApiParam(value = "max-age-cache")
+            @QueryParam(value = "max-age-cache") Integer maxagecache
+    ) throws NotFoundException, ArlasException {
+        CollectionReference collectionReference = exploreService.getCollectionReferenceService()
+                .getCollectionReference(collection);
+        if (collectionReference == null) {
+            throw new NotFoundException(collection);
+        }
+
+        if (agg == null || agg.size() == 0) {
+            agg = Collections.singletonList("geotile:" + collectionReference.params.centroidPath + ":interval-" + (z+3));
+        }
+
+        List<BoundingBox> bboxes = getBoundingBoxes(z, x, y, agg, collectionReference);
+        List<CompletableFuture<AggregationResponse>> futureList = new ArrayList<>();
+        AggregationTypeEnum aggType = null;
+        for (BoundingBox b : bboxes) {
+            Expression pwithinBbox = new Expression(collectionReference.params.centroidPath, OperatorEnum.within,
+                    b.getWest() + "," + b.getSouth() + ","
+                            + String.format(Locale.ROOT, "%.8f", b.getEast() - GEOHASH_EPSILON) + ","
+                            + String.format(Locale.ROOT, "%.8f", b.getNorth() - GEOHASH_EPSILON));
+            MixedRequest request = getGeoaggregateRequest(collectionReference,
+                    ParamsParser.getFilter(collectionReference, f, q, dateformat, b, pwithinBbox)
+                    , partitionFilter, columnFilter, agg);
+            aggType = ((AggregationsRequest) request.basicRequest).aggregations.get(0).type;
+
+            futureList.add(CompletableFuture.supplyAsync(() -> {
+                try {
+                    return exploreService.aggregate(request,collectionReference, true,
+                            ((AggregationsRequest) request.basicRequest).aggregations,0,
+                            System.nanoTime());
+                } catch (ArlasException e) {
+                    throw new RuntimeException(e);
+                }
+            }));
+
+        }
+
+        List<AggregationResponse> aggResponses = futureList.stream()
+                .map(CompletableFuture::join)
+                .collect(Collectors.toList());
+
+        return cache(Response.ok(toGeoJson(merge(aggResponses), aggType, Boolean.TRUE.equals(flat), Optional.of(z + "/" + x + "/" + y))), maxagecache);
+
+    }
+
     private List<BoundingBox> getBoundingBoxes(String geohash, List<String> agg, CollectionReference collectionReference) throws ArlasException {
         // we expect a 'geohash' aggregation model with an interval specified
         int interval = 0;
@@ -361,6 +486,38 @@ private List<BoundingBox> getBoundingBoxes(String geohash, List<String> agg, Col
             return Arrays.asList(bbox);
         }
     }
+
+    private List<BoundingBox> getBoundingBoxes(Integer z, Integer x, Integer y, List<String> agg, CollectionReference collectionReference) throws ArlasException {
+        // we expect a 'geotile' aggregation model with an interval specified
+        int interval = 0;
+        List<Interval> intervals = ParamsParser.getAggregations(collectionReference, agg).stream()
+                .filter(a -> a.type.equals(AggregationTypeEnum.geotile))
+                .map(a -> a.interval)
+                .collect(Collectors.toList());
+        if (intervals.size() > 0) {
+            interval = intervals.get(0).value.intValue();
+        }
+        if (interval - z > 7 || interval - z < 0) {
+            throw new InvalidParameterException("(interval - z) must be > 0 and <= 7");
+        }
+
+        BoundingBox bbox = GeoTileUtil.getBoundingBox(new Tile(x, y, z));
+        if (interval - z  == 7) {
+            LOGGER.debug("interval - z == 7");
+            // Split initial bbox in 4
+            double midLat = (bbox.getNorth() + bbox.getSouth()) / 2;
+            double midLon = (bbox.getWest() + bbox.getEast()) / 2;
+            return Arrays.asList(
+                    new BoundingBox(bbox.getNorth(), midLat, bbox.getWest(), midLon),
+                    new BoundingBox(bbox.getNorth(), midLat, midLon, bbox.getEast()),
+                    new BoundingBox(midLat, bbox.getSouth(), bbox.getWest(), midLon),
+                    new BoundingBox(midLat, bbox.getSouth(), midLon, bbox.getEast()));
+        } else {
+            LOGGER.debug("interval - z < 7");
+            return Arrays.asList(bbox);
+        }
+    }
+
     private AggregationResponse merge(List<AggregationResponse> aggResponses) {
         AggregationResponse result = new AggregationResponse();
         if (aggResponses.size() > 1) {
@@ -578,7 +735,7 @@ private FeatureCollection getFeatureCollection(MixedRequest request, CollectionR
         return fc;
     }
 
-    private FeatureCollection toGeoJson(AggregationResponse aggregationResponse, AggregationTypeEnum mainAggregationType, boolean flat, Optional<String> geohash) {
+    private FeatureCollection toGeoJson(AggregationResponse aggregationResponse, AggregationTypeEnum mainAggregationType, boolean flat, Optional<String> tile) {
         FeatureCollection fc = new FeatureCollection();
         List<AggregationResponse> elements = aggregationResponse.elements;
         if (!CollectionUtils.isEmpty(elements)) {
@@ -590,7 +747,10 @@ private FeatureCollection toGeoJson(AggregationResponse aggregationResponse, Agg
                        properties.put("count", element.count);
                        if (mainAggregationType == AggregationTypeEnum.geohash) {
                            properties.put("geohash", element.keyAsString);
-                           geohash.ifPresent(s -> properties.put("parent_geohash", s));
+                           tile.ifPresent(s -> properties.put("parent_geohash", s));
+                       } else if (mainAggregationType == AggregationTypeEnum.geotile) {
+                           properties.put("tile", element.keyAsString);
+                           tile.ifPresent(s -> properties.put("parent_tile", s));
                        } else {
                            properties.put("key", element.keyAsString);
                        }
diff --git a/docs/arlas-api-exploration.md b/docs/arlas-api-exploration.md
index 0bfbef1f9..ab3deb382 100644
--- a/docs/arlas-api-exploration.md
+++ b/docs/arlas-api-exploration.md
@@ -18,6 +18,7 @@ The table below lists the URL endpoints and their optional "parts". A part is co
 | /arlas/explore/`{collections}`/**_aggregate**?`aggregation` &`filter` & `form` | Aggregate the elements in the collection(s), given the filters and the aggregation parameters |
 | /arlas/explore/`{collections}`/**_geoaggregate**?`aggregation` &`filter` & `form` | Aggregate the elements in the collection(s) as features, given the filters and the aggregation parameters |
 | /arlas/explore/`{collections}`/**_geoaggregate**/`{geohash}`?`aggregation` &`filter` & `form` | Aggregate the elements in the collection(s) and localized in the given `{geohash}` as features, given the filters and the aggregation parameters |
+| /arlas/explore/`{collections}`/**_geoaggregate**/`{z}`/`{x}`/`{y}`?`aggregation` &`filter` & `form` | Aggregate the elements in the collection(s) and localized in the given `zxy` tile as features, given the filters and the aggregation parameters |
 | /arlas/explore/`{collections}`/**_suggest**?`filter` & `form` & `size` & `suggest` | Suggest the the n (n=`size`) most relevant terms given the filters |
 
 When multiple collections are permitted ({collections}), the comma is used for separating the collection names.
@@ -52,11 +53,11 @@ The other parts must be specified or not depending on the aggregation type. All
 
 | Parameter                 | Aggregation type                    | Description                                      |
 | ---------                 | -------------                       | ------------------------------------------------ |
-| **interval**              | `datehistogram, histogram, geohash` | mandatory                                        |
+| **interval**              | `datehistogram, histogram, geohash, geotile` | mandatory                                        |
 | **format**                | `datehistogram`                     | optional (default value : `yyyy-MM-dd-HH:mm:ss`) |
 | (**collect_field**,**collect_fct**) | All types                 | optional and multiple                            |
 | (**order**,**on**)        | `term, histogram, datehistogram`    | optional                                         |
-| **size**                  | `term, geohash`                     | optional                                         |
+| **size**                  | `term, geohash, geotile`             | optional                                         |
 | **include**               | `term`                              | optional                                         |
 | **aggregated_geometries** | All types                           | optional                                         |
 | **raw_geometries**        | All types                           | optional                                         |
@@ -68,7 +69,7 @@ The sub-parameters possible values are:
 
 | Parameter         | Values                                          | Description                              |
 | ----------------- | ----------------------------------------------  | ---------------------------------------- |
-| **{type}**        | `datehistogram`, `histogram`, `geohash`, `term` | Type of aggregation |
+| **{type}**        | `datehistogram`, `histogram`, `geohash`, `geotile`, `term` | Type of aggregation |
 | **{field}**       | {field}                                         | Aggregates on {field} |
 | **interval**      | {interval}                                      | Size of the intervals.(1)                   |
 | **format**        | [Date format](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-daterange-aggregation.html#date-format-pattern) for key aggregation | Date format for key aggregation.         |
@@ -78,7 +79,7 @@ The sub-parameters possible values are:
 | **on**            | `field,count,result` (3) (3')                       | {on} is set to specify whether the **order** is on the field name, on the count of the aggregation or the result of a metric subaggregation. |
 | **size**          | {size}                                          | Defines how many buckets should be returned. |
 | **include**       | Comma separated strings (4)                     | Specifies the values for which buckets will be created. |
-| **aggregated_geometries** | Comma separated strings : `bbox`, `centroid`, `geohash_center`, `geohash` |  Allows to specify a list of aggregated forms of geometries that represent the bucket (5)(6)|
+| **aggregated_geometries** | Comma separated strings : `bbox`, `centroid`, `tile_center`, `tile` |  Allows to specify a list of aggregated forms of geometries that represent the bucket (5)(6)|
 | **raw_geometries** | `{geo_field1}(+{field1}, ...);{geo_field2}(-{field2}, ...)` |  Allows to specify a list of raw geometries provided by hits that represent the bucket and that are elected by a sort (7)(8)|
 | **fetch_hits**     | `{optionalNumberOfHist}(+{field1}, {field2}, -{field3}, ...)`    | Specifies the number of hits to retrieve inside each aggregation bucket and which fields to include in the hits. The hits can be sorted according 0-* fields by preceding the field name by `+` for ascending sort, `-` for descending sort or nothing if no sort is desired on a field.(9)|
 
@@ -104,8 +105,8 @@ The `order` is applied on the first collect_fct `avg` (that is different from `g
 
  - `bbox`: returns the data extent (bbox) inside the bucket.
  - `centroid`: returns the centroid of data inside the bucket.
- - `geohash`: returns the 'geohash' extent that represents the bucket. This form is supported for **geohash** aggregation type only.
- - `geohash_center`: returns the 'center' of the geohash extent that represents the bucket. This form is supported for **geohash** aggregation type only.
+ - `tile`: returns the tile extent (zxy or geohash) of each bucket. This form is supported for **geohash** and **geotile** aggregation type only.
+ - `tile_center`: returns the 'center' of the tile extent that represents the bucket. This form is supported for **geohash** and **geotile** aggregation type only.
 
 (6) The response:
 
@@ -139,6 +140,7 @@ The `order` is applied on the first collect_fct `avg` (that is different from `g
 | ------------------- | ------------------- | ---------------------------------------- | ---------------------------------------- |
 | ***_aggregate***    | ***datehistogram*** | `{size}(year,quarter,month,week,day,hour,minute,second)` | Size of a time interval with the given unit (no space between number and unit). Size must be equal to 1 for year, quarter, month and week |
 | ***_geoaggregate*** | ***geohash***       | `{length}`                               | The geohash length: lower the length, greater is the surface of aggregation. See table below. |
+| ***_geoaggregate*** | ***geotile***       | `{zoom}`                                 | The tile zoom: lower the zoom, greater is the surface of aggregation. |
 | ***_aggregate***    | ***histogram***     | `{size}`                                 | The interval size of the numeric aggregation |
 | ***_aggregate***    | ***term***          | None                                     | None                                     |
 
@@ -161,7 +163,7 @@ The table below shows the metric dimensions for cells covered by various string
 
 **agg** parameter is multiple. Every agg parameter specified is a subaggregation of the previous one : the order matters.
 
-For **_geoaggregate** service, the first (main) aggregation must be geohash.
+For **_geoaggregate** service, the first (main) aggregation must be geohash,or geotile.
 
 ---
 ### Part: `filter`
@@ -314,6 +316,7 @@ The following endpoints use this header:
 | /arlas/explore/`{collections}`/**_tile**/`{z}`/`{x}`/`{y}`.png | Return a 403 if one of the filter, projection or page fields is not in the column filter. Return a 403 if target collection is not available. |
 | /arlas/explore/`{collections}`/**_geoaggregate** | Return a 403 if one of the aggregation or filter fields is not in the column filter. Return a 403 if target collection is not available. |
 | /arlas/explore/`{collections}`/**_geoaggregate**/`{geohash}` | Return a 403 if one of the aggregation or filter fields is not in the column filter. Return a 403 if target collection is not available. |
+| /arlas/explore/`{collections}`/**_geoaggregate**/`{z}`/`{x}`/`{y}` | Return a 403 if one of the aggregation or filter fields is not in the column filter. Return a 403 if target collection is not available. |
 | /arlas/explore/ogc/**opensearch**/{collection} | Only fields matching this filter will be returned. Return a 403 if target collection is not available. |
 
 On top of that, a query ("q" parameter) filter MUST target a specific field:


Test patch:
diff --git a/arlas-tests/src/test/java/io/arlas/server/rest/explore/AbstractAggregatedTest.java b/arlas-tests/src/test/java/io/arlas/server/rest/explore/AbstractAggregatedTest.java
index 48cbc1cd3..f36976870 100644
--- a/arlas-tests/src/test/java/io/arlas/server/rest/explore/AbstractAggregatedTest.java
+++ b/arlas-tests/src/test/java/io/arlas/server/rest/explore/AbstractAggregatedTest.java
@@ -32,6 +32,7 @@
 import java.util.Arrays;
 import java.util.List;
 
+import static io.arlas.server.utils.CheckParams.GEO_AGGREGATION_TYPE_ENUMS;
 import static io.restassured.RestAssured.given;
 
 public abstract class AbstractAggregatedTest extends AbstractFormattedTest {
@@ -67,12 +68,12 @@ public void testGeohashAggregate() throws Exception {
         handleMatchingGeohashAggregateCenter(post(aggregationRequest), 32, 16, 25, -169.453125F, -79.453125F, 169.453125F, 79.453125F);
         handleMatchingGeohashAggregateCenter(get("geohash:geo_params.centroid:interval-1"), 32, 16, 25, -169.453125F, -79.453125F, 169.453125F, 79.453125F);
 
-        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.GEOHASHCENTER);
+        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.TILECENTER);
         handleMatchingGeohashAggregate(post(aggregationRequest), 32, 16, 25);
-        handleMatchingGeohashAggregate(get("geohash:geo_params.centroid:interval-1:aggregated_geometries-geohash_center"), 32, 16, 25);
+        handleMatchingGeohashAggregate(get("geohash:geo_params.centroid:interval-1:aggregated_geometries-tile_center"), 32, 16, 25);
 
         handleMatchingGeohashAggregateCenter(post(aggregationRequest), 32, 16, 25, -169.453125F, -79.453125F, 169.453125F, 79.453125F);
-        handleMatchingGeohashAggregateCenter(get("geohash:geo_params.centroid:interval-1:aggregated_geometries-geohash_center"), 32, 16, 25, -169.453125F, -79.453125F, 169.453125F, 79.453125F);
+        handleMatchingGeohashAggregateCenter(get("geohash:geo_params.centroid:interval-1:aggregated_geometries-tile_center"), 32, 16, 25, -169.453125F, -79.453125F, 169.453125F, 79.453125F);
 
         aggregationRequest.aggregations.get(0).aggregatedGeometries = null;
         aggregationRequest.aggregations.get(0).rawGeometries = Arrays.asList(new RawGeometry("geo_params.geometry"));
@@ -167,10 +168,10 @@ public void testGeohashAggregate() throws Exception {
         handleMatchingGeohashAggregateWithGeocentroidBucket(get("geohash:geo_params.centroid:interval-1:aggregated_geometries-centroid"),
                 32, 16, 25, 0, -155.00000031664968F, -65.00000014901161F, 154.99999981373549F, 64.99999981373549F);
 
-        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.GEOHASH);
+        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.TILE);
         handleMatchingAggregateWithGeometry(post(aggregationRequest),
                 32, 16, 25, -180F, -90F, 180F, 90F);
-        handleMatchingAggregateWithGeometry(get("geohash:geo_params.centroid:interval-1:aggregated_geometries-geohash"),
+        handleMatchingAggregateWithGeometry(get("geohash:geo_params.centroid:interval-1:aggregated_geometries-tile"),
                 32, 16, 25, -180F, -90F, 180F, 90F);
 
         aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.BBOX);
@@ -194,6 +195,151 @@ public void testGeohashAggregate() throws Exception {
                 32, 16, 25, 1, -170.00000000931323F, -80.00000000931323F, 169.9999999254942F, 79.99999996740371F);
 
 
+    }
+
+    @Test
+    public void testGeotileAggregate() throws Exception {
+        //GEOTILE
+        aggregationRequest.aggregations.get(0).type = AggregationTypeEnum.geotile;
+        aggregationRequest.aggregations.get(0).field = "geo_params.centroid";
+        aggregationRequest.aggregations.get(0).interval = new Interval(6, null); //"6";
+        handleMatchingGeohashAggregate(post(aggregationRequest), 595, 1, 1);
+        handleMatchingGeohashAggregate(get("geotile:geo_params.centroid:interval-6"), 595, 1, 1);
+
+        aggregationRequest.aggregations.get(0).interval = new Interval(1, null); //"1";
+        handleMatchingGeohashAggregate(post(aggregationRequest), 4, 136, 162);
+        handleMatchingGeohashAggregate(get("geotile:geo_params.centroid:interval-1"), 4, 136, 162);
+
+        handleMatchingGeohashAggregateCenter(post(aggregationRequest), 4, 136, 162, -90F, -42.525566F, 90F, 42.525565F);
+        handleMatchingGeohashAggregateCenter(get("geotile:geo_params.centroid:interval-1"), 4, 136, 162, -90F, -42.525566F, 90F, 42.525565F);
+
+        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.TILECENTER);
+        handleMatchingGeohashAggregate(post(aggregationRequest), 4, 136, 162);
+        handleMatchingGeohashAggregate(get("geotile:geo_params.centroid:interval-1:aggregated_geometries-tile_center"), 4, 136, 162);
+
+        handleMatchingGeohashAggregateCenter(post(aggregationRequest), 4, 136, 162, -90F, -42.525566F, 90F, 42.525565F);
+        handleMatchingGeohashAggregateCenter(get("geotile:geo_params.centroid:interval-1:aggregated_geometries-tile_center"), 4, 136, 162, -90F, -42.525566F, 90F, 42.525565F);
+
+        aggregationRequest.aggregations.get(0).aggregatedGeometries = null;
+        aggregationRequest.aggregations.get(0).rawGeometries = Arrays.asList(new RawGeometry("geo_params.geometry"));
+        handleMatchingAggregateWithGeometry(post(aggregationRequest), 4, 136, 162, -171F, -81F, 141F, 51F);
+        handleMatchingAggregateWithGeometry(get("geotile:geo_params.centroid:interval-1:raw_geometries-geo_params.geometry"), 4, 136, 162, -171F, -81F, 141F, 51F);
+
+        aggregationRequest.aggregations.get(0).rawGeometries = Arrays.asList(new RawGeometry("geo_params.geometry", "-params.startdate"));
+        handleMatchingAggregateWithGeometry(post(aggregationRequest), 4, 136, 162, -141F, -51F, 171F, 81F);
+        handleMatchingAggregateWithGeometry(get("geotile:geo_params.centroid:interval-1:raw_geometries-geo_params.geometry(-params.startdate)"), 4, 136, 162, -141F, -51F, 171F, 81F);
+
+        aggregationRequest.aggregations.get(0).rawGeometries = Arrays.asList(new RawGeometry("geo_params.geometry", "params.age"));
+        handleMatchingAggregateWithGeometry(post(aggregationRequest), 4, 136, 162, -161F, -99F, 161F, 71F);
+        handleMatchingAggregateWithGeometry(get("geotile:geo_params.centroid:interval-1:raw_geometries-geo_params.geometry(params.age)"), 4, 136, 162, -161F, -99F, 161F, 71F);
+
+        aggregationRequest.aggregations.get(0).rawGeometries = Arrays.asList(new RawGeometry("geo_params.geometry", "-params.age"));
+        handleMatchingAggregateWithGeometry(post(aggregationRequest), 4, 136, 162, -171F, -81F, 171F, 81F);
+        handleMatchingAggregateWithGeometry(get("geotile:geo_params.centroid:interval-1:raw_geometries-geo_params.geometry(-params.age)"), 4, 136, 162, -171F, -81F, 171F, 81F);
+
+        aggregationRequest.aggregations.get(0).rawGeometries = null;
+
+        aggregationRequest.aggregations.get(0).fetchHits = new HitsFetcher(3, Arrays.asList("params.country", "params.startdate"));
+        handleMatchingAggregateWithFetchedHits(post(aggregationRequest), 4, 3, "params.country", "params.startdate");
+        handleMatchingAggregateWithFetchedHits(get("geotile:geo_params.centroid:interval-1:fetch_hits-3(params.country,params.startdate)"), 4, 3, "params.country", "params.startdate");
+
+        aggregationRequest.aggregations.get(0).fetchHits = new HitsFetcher(3, Arrays.asList("-params.startdate"));
+        handleMatchingAggregateWithSortedFetchedDates(post(aggregationRequest), 4, 3, 980000, 1263600, "params.startdate");
+        handleMatchingAggregateWithSortedFetchedDates(get("geotile:geo_params.centroid:interval-1:fetch_hits-3(-params.startdate)"), 4, 3, 980000, 1263600, "params.startdate");
+        aggregationRequest.aggregations.get(0).fetchHits = null;
+
+        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+        aggregationRequest.aggregations.get(0).metrics.add(new Metric("params.startdate", CollectionFunction.AVG));
+        handleMatchingGeohashAggregateWithCollect(post(aggregationRequest),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField,"avg", 873600F, 1133825F);
+        handleMatchingGeohashAggregateWithCollect(get("geotile:geo_params.centroid:interval-1:collect_field-params.startdate:collect_fct-avg"),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField,"avg", 873600F, 1133825F);
+
+        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+        aggregationRequest.aggregations.get(0).metrics.add(new Metric("params.startdate", CollectionFunction.CARDINALITY));
+        handleMatchingGeohashAggregateWithCollect(post(aggregationRequest),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField,"cardinality", 116, 162F);
+        handleMatchingGeohashAggregateWithCollect(get("geotile:geo_params.centroid:interval-1:collect_field-params.startdate:collect_fct-cardinality"),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField,"cardinality", 116, 162F);
+
+        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+        aggregationRequest.aggregations.get(0).metrics.add(new Metric("params.startdate", CollectionFunction.MAX));
+        handleMatchingGeohashAggregateWithCollect(post(aggregationRequest),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField, "max", 990000F, 1263600F);
+        handleMatchingGeohashAggregateWithCollect(get("geotile:geo_params.centroid:interval-1:collect_field-params.startdate:collect_fct-max"),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField,"max", 990000F, 1263600F);
+
+        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+        aggregationRequest.aggregations.get(0).metrics.add(new Metric("params.startdate", CollectionFunction.MIN));
+        handleMatchingGeohashAggregateWithCollect(post(aggregationRequest),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField,"min", 763600F, 1010000F);
+        handleMatchingGeohashAggregateWithCollect(get("geotile:geo_params.centroid:interval-1:collect_field-params.startdate:collect_fct-min"),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField,"min", 763600F, 1010000F);
+
+        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+        aggregationRequest.aggregations.get(0).metrics.add(new Metric("params.startdate", CollectionFunction.SUM));
+        handleMatchingGeohashAggregateWithCollect(post(aggregationRequest),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField,"sum", 129329200F, 168739200F);
+        handleMatchingGeohashAggregateWithCollect(get("geotile:geo_params.centroid:interval-1:collect_field-params.startdate:collect_fct-sum"),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField,"sum", 129329200F, 168739200F);
+
+        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+        aggregationRequest.aggregations.get(0).metrics.add(new Metric("params.startdate", CollectionFunction.SUM));
+        aggregationRequest.aggregations.get(0).metrics.add(new Metric("params.age", CollectionFunction.MAX));
+        handleMatchingGeohashAggregateWithMultiCollect(post(aggregationRequest),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField, aggregationRequest.aggregations.get(0).metrics.get(1).collectField, "sum", "max", 129329200F, 168739200F, 13600F,13600F);
+        handleMatchingGeohashAggregateWithMultiCollect(get("geotile:geo_params.centroid:interval-1:collect_field-params.startdate:collect_fct-sum:collect_field-params.age:collect_fct-max"),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField, aggregationRequest.aggregations.get(0).metrics.get(1).collectField,"sum", "max", 129329200F, 168739200F, 13600F,13600F);
+
+
+        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+        aggregationRequest.aggregations.get(0).metrics.add(new Metric("geo_params.centroid", CollectionFunction.GEOCENTROID));
+        handleMatchingGeohashAggregateWithGeocentroidCollect(post(aggregationRequest),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField, "geocentroid", -90.00000003944425F, -40.00000001862645F, 84.9999999627471F, 44.99999997904524F);
+        handleMatchingGeohashAggregateWithGeocentroidCollect(get("geotile:geo_params.centroid:interval-1:collect_fct-geocentroid:collect_field-geo_params.centroid"),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField, "geocentroid", -90.00000003944425F, -40.00000001862645F, 84.9999999627471F, 44.99999997904524F);
+
+        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+        aggregationRequest.aggregations.get(0).metrics.add(new Metric("geo_params.centroid", CollectionFunction.GEOBBOX));
+        handleMatchingGeohashAggregateWithGeoBboxCollect(post(aggregationRequest),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField, "geobbox", -170.00000000931323F, -80.00000000931323F, 169.9999999254942F, 79.99999996740371F);
+        handleMatchingGeohashAggregateWithGeoBboxCollect(get("geotile:geo_params.centroid:interval-1:collect_fct-geobbox:collect_field-geo_params.centroid"),
+                4, 136, 162, aggregationRequest.aggregations.get(0).metrics.get(0).collectField,"geobbox", -170.00000000931323F, -80.00000000931323F, 169.9999999254942F, 79.99999996740371F);
+
+        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.CENTROID);
+        handleMatchingGeohashAggregateWithGeocentroidBucket(post(aggregationRequest),
+                4, 136, 162, 0, -90.00000003944425F, -40.00000001862645F, 84.9999999627471F, 44.99999997904524F);
+        handleMatchingGeohashAggregateWithGeocentroidBucket(get("geotile:geo_params.centroid:interval-1:aggregated_geometries-centroid"),
+                4, 136, 162, 0, -90.00000003944425F, -40.00000001862645F, 84.9999999627471F, 44.99999997904524F);
+
+        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.TILE);
+        handleMatchingAggregateWithGeometry(post(aggregationRequest),
+                4, 136, 162, -180F, -90F, 180F, 90F);
+        handleMatchingAggregateWithGeometry(get("geotile:geo_params.centroid:interval-1:aggregated_geometries-tile"),
+                4, 136, 162, -180F, -90F, 180F, 90F);
+
+        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.BBOX);
+        handleMatchingGeohashAggregateWithGeoBboxBucket(post(aggregationRequest),
+                4, 136, 162, 0, -170.00000000931323F, -80.00000000931323F, 169.9999999254942F, 79.99999996740371F);
+        handleMatchingGeohashAggregateWithGeoBboxBucket(get("geotile:geo_params.centroid:interval-1:aggregated_geometries-bbox"),
+                4, 136, 162, 0, -170.00000000931323F, -80.00000000931323F, 169.9999999254942F, 79.99999996740371F);
+
+        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+        aggregationRequest.aggregations.get(0).metrics.add(new Metric("geo_params.centroid", CollectionFunction.GEOBBOX));
+        handleMatchingGeohashAggregateWithGeoBboxBucket(post(aggregationRequest),
+                4, 136, 162, 0, -170.00000000931323F, -80.00000000931323F, 169.9999999254942F, 79.99999996740371F);
+        handleMatchingGeohashAggregateWithGeoBboxBucket(get("geotile:geo_params.centroid:interval-1:aggregated_geometries-bbox:collect_fct-geobbox:collect_field-geo_params.centroid"),
+                4, 136, 162, 0, -170.00000000931323F, -80.00000000931323F, 169.9999999254942F, 79.99999996740371F);
+
+        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+        aggregationRequest.aggregations.get(0).metrics.add(new Metric("geo_params.centroid", CollectionFunction.GEOCENTROID));
+        handleMatchingGeohashAggregateWithGeoBboxBucket(post(aggregationRequest),
+                4, 136, 162, 1, -170.00000000931323F, -80.00000000931323F, 169.9999999254942F, 79.99999996740371F);
+        handleMatchingGeohashAggregateWithGeoBboxBucket(get("geotile:geo_params.centroid:interval-1:aggregated_geometries-bbox:collect_fct-geocentroid:collect_field-geo_params.centroid"),
+                4, 136, 162, 1, -170.00000000931323F, -80.00000000931323F, 169.9999999254942F, 79.99999996740371F);
+
+
     }
 
     @Test
@@ -767,88 +913,101 @@ public void testInvalidAggregateParameters() throws Exception {
         handleInvalidParameters(post(invalidAggregationRequest));
 
         //INVALID GEOHASH
-        aggregationRequest.aggregations.get(0).type = AggregationTypeEnum.geohash;
-        handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("geohash"));
-
-        aggregationRequest.aggregations.get(0).field = "params.job";
-        handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("geohash:params.job"));
-
-        aggregationRequest.aggregations.get(0).interval = new Interval(1, null);
-        handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("geohash:params.job:interval-1"));
-
-        aggregationRequest.aggregations.get(0).field = "geo_centroid";
-        aggregationRequest.aggregations.get(0).interval = new Interval(0, null);
-        handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("geohash:geo_params.centroid:interval-0"));
-
-        aggregationRequest.aggregations.get(0).interval = new Interval(13, null);
-        handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("geohash:geo_params.centroid:interval-13"));
-        aggregationRequest.aggregations.get(0).interval = new Interval(1, null);
-
-        aggregationRequest.aggregations.get(0).rawGeometries = Arrays.asList(new RawGeometry("toto.geometry"));
-        handleNotFoundField(post(aggregationRequest));
-        handleNotFoundField(get("geohash:geo_params.centroid:interval-1:raw_geometries-toto.geometry"));
-        aggregationRequest.aggregations.get(0).rawGeometries = null;
-
-        aggregationRequest.aggregations.get(0).order = Order.asc;
-        aggregationRequest.aggregations.get(0).on = OrderOn.count;
-        handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("geohash:geo_params.centroid:interval-1:order-asc:on-count"));
-
-        aggregationRequest.aggregations.get(0).on = OrderOn.field;
-        handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("geohash:geo_params.centroid:interval-1:order-asc:on-field"));
-
-        aggregationRequest.aggregations.get(0).order = Order.desc;
-        aggregationRequest.aggregations.get(0).on = OrderOn.result;
-        handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("geohash:geo_params.centroid:interval-1:order-desc:on-result"));
-
-        aggregationRequest.aggregations.get(0).order = Order.asc;
-        aggregationRequest.aggregations.get(0).on = OrderOn.result;
-        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
-        aggregationRequest.aggregations.get(0).metrics.add(new Metric("geo_params.centroid", CollectionFunction.GEOCENTROID));
-        handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("geohash:geo_params.centroid:interval-1:order-asc:on-result:collect_fct-geocentroid:collect_field-geo_params.centroid"));
-
-        aggregationRequest.aggregations.get(0).order = null;
-        aggregationRequest.aggregations.get(0).on = null;
-        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
-        aggregationRequest.aggregations.get(0).metrics.add(new Metric("params.job", CollectionFunction.GEOCENTROID));
-        handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("geohash:geo_params.centroid:interval-1:collect_field-params.job:collect_fct-geocentroid"));
-
-        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
-        aggregationRequest.aggregations.get(0).metrics.add(new Metric("foo", null));
-        handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("geohash:geo_params.centroid:interval-1:collect_field-foo"));
-
-        aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
-        aggregationRequest.aggregations.get(0).include = "foo";
-        handleInvalidParameters(post(invalidAggregationRequest));
-        handleInvalidParameters(get("geohash:geo_params.centroid:interval-1:include-foo"));
-
-        invalidAggregationRequest.invalidAggregations.get(0).type = "geohash";
-        invalidAggregationRequest.invalidAggregations.get(0).field = "geo_params.centroid";
-        invalidAggregationRequest.invalidAggregations.get(0).interval = new Interval(1, null);
-        invalidAggregationRequest.invalidAggregations.get(0).metrics = new ArrayList<>();
-        invalidAggregationRequest.invalidAggregations.get(0).metrics.add(new InvalidMetric("foo", "bar"));
-        handleInvalidParameters(post(invalidAggregationRequest));
-        handleInvalidParameters(get("geohash:geo_params.centroid:interval-1:collect_field-foo:collect_fct-bar"));
-        invalidAggregationRequest.invalidAggregations.get(0).metrics = null;
-
-        invalidAggregationRequest.invalidAggregations.get(0).aggregatedGeometries = Arrays.asList("boo");
-        handleInvalidParameters(post(invalidAggregationRequest));
-        handleInvalidParameters(get("geohash:geo_params.centroid:interval-1:aggregated_geometries-boo"));
-
-        aggregationRequest.aggregations.get(0).include = null;
-        aggregationRequest.aggregations.get(0).interval = null;
-        handleInvalidParameters(post(invalidAggregationRequest));
-        handleInvalidParameters(get("geohash:geo_params.centroid"));
+        for (AggregationTypeEnum geoAggTypeEnum : GEO_AGGREGATION_TYPE_ENUMS) {
+            String geoAggType = geoAggTypeEnum.name();
+            aggregationRequest.aggregations.get(0).type = geoAggTypeEnum;
+            handleInvalidParameters(post(aggregationRequest));
+            handleInvalidParameters(get(geoAggType));
+
+            aggregationRequest.aggregations.get(0).field = "params.job";
+            handleInvalidParameters(post(aggregationRequest));
+            handleInvalidParameters(get(geoAggType+":params.job"));
+
+            aggregationRequest.aggregations.get(0).interval = new Interval(1, null);
+            handleInvalidParameters(post(aggregationRequest));
+            handleInvalidParameters(get(geoAggType+":params.job:interval-1"));
+
+            aggregationRequest.aggregations.get(0).field = "geo_centroid";
+            switch(geoAggTypeEnum) {
+                case geohash:
+                    aggregationRequest.aggregations.get(0).interval = new Interval(0, null);
+                    handleInvalidParameters(post(aggregationRequest));
+                    handleInvalidParameters(get(geoAggType+":geo_params.centroid:interval-0"));
+
+                    aggregationRequest.aggregations.get(0).interval = new Interval(13, null);
+                    handleInvalidParameters(post(aggregationRequest));
+                    handleInvalidParameters(get(geoAggType+":geo_params.centroid:interval-13"));
+                    break;
+                case geotile:
+                    aggregationRequest.aggregations.get(0).interval = new Interval(30, null);
+                    handleInvalidParameters(post(aggregationRequest));
+                    handleInvalidParameters(get(geoAggType+":geo_params.centroid:interval-30"));
+                    break;
+            }
+
+            aggregationRequest.aggregations.get(0).interval = new Interval(1, null);
+
+            aggregationRequest.aggregations.get(0).rawGeometries = Arrays.asList(new RawGeometry("toto.geometry"));
+            handleNotFoundField(post(aggregationRequest));
+            handleNotFoundField(get(geoAggType+":geo_params.centroid:interval-1:raw_geometries-toto.geometry"));
+            aggregationRequest.aggregations.get(0).rawGeometries = null;
+
+            aggregationRequest.aggregations.get(0).order = Order.asc;
+            aggregationRequest.aggregations.get(0).on = OrderOn.count;
+            handleInvalidParameters(post(aggregationRequest));
+            handleInvalidParameters(get(geoAggType+":geo_params.centroid:interval-1:order-asc:on-count"));
+
+            aggregationRequest.aggregations.get(0).on = OrderOn.field;
+            handleInvalidParameters(post(aggregationRequest));
+            handleInvalidParameters(get(geoAggType+":geo_params.centroid:interval-1:order-asc:on-field"));
+
+            aggregationRequest.aggregations.get(0).order = Order.desc;
+            aggregationRequest.aggregations.get(0).on = OrderOn.result;
+            handleInvalidParameters(post(aggregationRequest));
+            handleInvalidParameters(get(geoAggType+":geo_params.centroid:interval-1:order-desc:on-result"));
+
+            aggregationRequest.aggregations.get(0).order = Order.asc;
+            aggregationRequest.aggregations.get(0).on = OrderOn.result;
+            aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+            aggregationRequest.aggregations.get(0).metrics.add(new Metric("geo_params.centroid", CollectionFunction.GEOCENTROID));
+            handleInvalidParameters(post(aggregationRequest));
+            handleInvalidParameters(get(geoAggType+":geo_params.centroid:interval-1:order-asc:on-result:collect_fct-geocentroid:collect_field-geo_params.centroid"));
+
+            aggregationRequest.aggregations.get(0).order = null;
+            aggregationRequest.aggregations.get(0).on = null;
+            aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+            aggregationRequest.aggregations.get(0).metrics.add(new Metric("params.job", CollectionFunction.GEOCENTROID));
+            handleInvalidParameters(post(aggregationRequest));
+            handleInvalidParameters(get(geoAggType+":geo_params.centroid:interval-1:collect_field-params.job:collect_fct-geocentroid"));
+
+            aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+            aggregationRequest.aggregations.get(0).metrics.add(new Metric("foo", null));
+            handleInvalidParameters(post(aggregationRequest));
+            handleInvalidParameters(get(geoAggType+":geo_params.centroid:interval-1:collect_field-foo"));
+
+            aggregationRequest.aggregations.get(0).metrics = new ArrayList<>();
+            aggregationRequest.aggregations.get(0).include = "foo";
+            handleInvalidParameters(post(invalidAggregationRequest));
+            handleInvalidParameters(get(geoAggType+":geo_params.centroid:interval-1:include-foo"));
+
+            invalidAggregationRequest.invalidAggregations.get(0).type = geoAggType;
+            invalidAggregationRequest.invalidAggregations.get(0).field = "geo_params.centroid";
+            invalidAggregationRequest.invalidAggregations.get(0).interval = new Interval(1, null);
+            invalidAggregationRequest.invalidAggregations.get(0).metrics = new ArrayList<>();
+            invalidAggregationRequest.invalidAggregations.get(0).metrics.add(new InvalidMetric("foo", "bar"));
+            handleInvalidParameters(post(invalidAggregationRequest));
+            handleInvalidParameters(get(geoAggType+":geo_params.centroid:interval-1:collect_field-foo:collect_fct-bar"));
+            invalidAggregationRequest.invalidAggregations.get(0).metrics = null;
+
+            invalidAggregationRequest.invalidAggregations.get(0).aggregatedGeometries = Arrays.asList("boo");
+            handleInvalidParameters(post(invalidAggregationRequest));
+            handleInvalidParameters(get(geoAggType+":geo_params.centroid:interval-1:aggregated_geometries-boo"));
+
+            aggregationRequest.aggregations.get(0).include = null;
+            aggregationRequest.aggregations.get(0).interval = null;
+            handleInvalidParameters(post(invalidAggregationRequest));
+            handleInvalidParameters(get(geoAggType+":geo_params.centroid"));
+        }
 
         // INVALID DATE HISTOGRAM
         aggregationRequest.aggregations.get(0).type = AggregationTypeEnum.datehistogram;
@@ -879,9 +1038,9 @@ public void testInvalidAggregateParameters() throws Exception {
         handleInvalidParameters(get("datehistogram:params.startdate:interval-day"));
 
         aggregationRequest.aggregations.get(0).interval = new Interval(1, UnitEnum.day); // "1day";
-        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.GEOHASH);
+        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.TILE);
         handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("datehistogram:params.startdate:interval-1day:aggregated_geometries-geohash"));
+        handleInvalidParameters(get("datehistogram:params.startdate:interval-1day:aggregated_geometries-tile"));
         aggregationRequest.aggregations.get(0).aggregatedGeometries = null;
 
         // INVALID HISTOGRAM
@@ -904,9 +1063,9 @@ public void testInvalidAggregateParameters() throws Exception {
         handleInvalidParameters(get("histogram:params.job"));
 
         aggregationRequest.aggregations.get(0).interval = new Interval(100000, null); // "1day";
-        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.GEOHASH);
+        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.TILE);
         handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("histogram:params.startdate:interval-100000:aggregated_geometries-geohash"));
+        handleInvalidParameters(get("histogram:params.startdate:interval-100000:aggregated_geometries-tile"));
         aggregationRequest.aggregations.get(0).aggregatedGeometries = null;
         aggregationRequest.aggregations.get(0).interval = null;
 
@@ -919,9 +1078,9 @@ public void testInvalidAggregateParameters() throws Exception {
         // INVALID AGGERAGATED_GEOMETY
         aggregationRequest.aggregations.get(0).type = AggregationTypeEnum.term;
         aggregationRequest.aggregations.get(0).interval = null;
-        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.GEOHASH);
+        aggregationRequest.aggregations.get(0).aggregatedGeometries = Arrays.asList(AggregatedGeometryEnum.TILE);
         handleInvalidParameters(post(aggregationRequest));
-        handleInvalidParameters(get("term:params.job:aggregated_geometries-geohash"));
+        handleInvalidParameters(get("term:params.job:aggregated_geometries-tile"));
 
         invalidAggregationRequest.invalidAggregations.get(0).type = "term";
         invalidAggregationRequest.invalidAggregations.get(0).field = "params.job";
@@ -988,12 +1147,14 @@ public void testAggregateParametersWithUnavailableColums() throws Exception {
     @Test
     public void testNotImplementedAggregateParameters() throws Exception {
         //NOT IMPLEMENTED PARAMETER
-        aggregationRequest.aggregations.get(0).type = AggregationTypeEnum.geohash;
-        aggregationRequest.aggregations.get(0).field = "geo_centroid";
-        aggregationRequest.aggregations.get(0).interval = new Interval(1, null);
-        aggregationRequest.aggregations.get(0).size = "1";
-        handleNotImplementedParameters(post(aggregationRequest));
-        handleNotImplementedParameters(get("geohash:geo_params.centroid:interval-1:size-1"));
+        for (AggregationTypeEnum geoAggTypeEnum : GEO_AGGREGATION_TYPE_ENUMS) {
+            aggregationRequest.aggregations.get(0).type = geoAggTypeEnum;
+            aggregationRequest.aggregations.get(0).field = "geo_centroid";
+            aggregationRequest.aggregations.get(0).interval = new Interval(1, null);
+            aggregationRequest.aggregations.get(0).size = "1";
+            handleNotImplementedParameters(post(aggregationRequest));
+            handleNotImplementedParameters(get(geoAggTypeEnum.name()+":geo_params.centroid:interval-1:size-1"));
+        }
     }
 
     //----------------------------------------------------------------
diff --git a/arlas-tests/src/test/java/io/arlas/server/rest/explore/AbstractGeohashTiledTest.java b/arlas-tests/src/test/java/io/arlas/server/rest/explore/AbstractGeohashTiledTest.java
index 8fa37ec48..81449446b 100644
--- a/arlas-tests/src/test/java/io/arlas/server/rest/explore/AbstractGeohashTiledTest.java
+++ b/arlas-tests/src/test/java/io/arlas/server/rest/explore/AbstractGeohashTiledTest.java
@@ -19,8 +19,6 @@
 
 package io.arlas.server.rest.explore;
 
-import io.arlas.server.model.enumerations.OperatorEnum;
-import io.arlas.server.model.request.Expression;
 import io.restassured.response.ValidatableResponse;
 import io.restassured.specification.RequestSpecification;
 import org.junit.Test;
@@ -33,30 +31,56 @@
 public abstract class AbstractGeohashTiledTest extends AbstractAggregatedTest {
 
     @Test
-    public void testGeohashTile() throws Exception {
+    public void testGeohash() throws Exception {
         //GEOHASH
-        // precision = geohashLength OR precision < geohashLength  ==> we should have one feauture maximum
-        handleGeohashTileGreaterThanPrecision(geohashTileGet("geohash:geo_params.centroid:interval-2", "yn"), 2, "yn");
-        handleGeohashTileGreaterThanPrecision(geohashTileGet("geohash:geo_params.centroid:interval-1", "ynp"), 1, "ynp");
+        // precision = geohashLength OR precision < geohashLength  ==> we should have one feature maximum
+        handleGeohashTileGreaterThanPrecision(tileGet("geohash:geo_params.centroid:interval-2", "yn"), 2, "yn");
+        handleGeohashTileGreaterThanPrecision(tileGet("geohash:geo_params.centroid:interval-1", "ynp"), 1, "ynp");
 
         // precision > geohashLength  ==> we could have more than one feature
-        handleGeohashTileLessThanPrecision(geohashTileGet("geohash:geo_params.centroid:interval-3", "yn"), 2, "yn");
+        handleGeohashTileLessThanPrecision(tileGet("geohash:geo_params.centroid:interval-3", "yn"), 2, "yn");
 
         String pwithin = "geo_params.centroid:within:98,79,101,81";
-        handleGeohashTileLessThanPrecision(geohashTilePwithinGet("geohash:geo_params.centroid:interval-3", Arrays.asList(pwithin), "yn"), 1, "yn");
+        handleGeohashTileLessThanPrecision(tilePwithinGet("geohash:geo_params.centroid:interval-3", Arrays.asList(pwithin), "yn"), 1, "yn");
 
         pwithin = "geo_params.centroid:within:98,79,101,81;geo_params.centroid:within:108,79,111,81";
-        handleGeohashTileLessThanPrecision(geohashTilePwithinGet("geohash:geo_params.centroid:interval-3", Arrays.asList(pwithin), "y"), 2, "yn");
+        handleGeohashTileLessThanPrecision(tilePwithinGet("geohash:geo_params.centroid:interval-3", Arrays.asList(pwithin), "y"), 2, "yn");
 
         pwithin = "geo_params.centroid:within:98,79,101,81";
         String pwithin2 = "geo_params.centroid:within:98,79,111,81";
-        handleGeohashTileLessThanPrecision(geohashTilePwithinGet("geohash:geo_params.centroid:interval-3", Arrays.asList(pwithin, pwithin2), "y"), 1, "yn");
+        handleGeohashTileLessThanPrecision(tilePwithinGet("geohash:geo_params.centroid:interval-3", Arrays.asList(pwithin, pwithin2), "y"), 1, "yn");
 
         pwithin = "geo_params.centroid:within:180,0,-165,5";
-        handleGeohashTileLessThanPrecision(geohashTilePwithinGet("geohash:geo_params.centroid:interval-3", Arrays.asList(pwithin), "80"), 1, "80");
+        handleGeohashTileLessThanPrecision(tilePwithinGet("geohash:geo_params.centroid:interval-3", Arrays.asList(pwithin), "80"), 1, "80");
 
         pwithin = "geo_params.centroid:within:-5,0,0,5";
-        handleGeohashTileDisjointFromPwithin(geohashTilePwithinGet("geohash:geo_params.centroid:interval-3", Arrays.asList(pwithin), "yn"));
+        handleGeohashTileDisjointFromPwithin(tilePwithinGet("geohash:geo_params.centroid:interval-3", Arrays.asList(pwithin), "yn"));
+
+    }
+
+    @Test
+    public void testGeotile() throws Exception {
+        //GEOTILE
+        // precision =  '{z}'  ==> we should have one feature maximum
+        handleGeotileGreaterThanPrecision(tileGet("geotile:geo_params.centroid:interval-5", "5/4/3"), 1, "5/4/3");
+
+        // precision > '{z}'  ==> we could have more than one feature
+        handleGeotileLessThanPrecision(tileGet("geotile:geo_params.centroid:interval-6", "4/3/3"), 2, "4/3/3");
+
+        //GEOTILE 3/1/1 [west=-135.0, south=66.51326044311186, east=-90.0, north=79.17133464081945 ]
+        //GEOTILE 4/3/3 [west=-112.5, south=66.51326044311186, east=-90.0, north=74.01954331150228]
+        String pwithin = "geo_params.centroid:within:-111,68,-109,71";
+        handleGeotileLessThanPrecision(tilePwithinGet("geotile:geo_params.centroid:interval-4", Arrays.asList(pwithin), "3/1/1"), 1, "4/3/3");
+
+        pwithin = "geo_params.centroid:within:-111,68,-109,71;geo_params.centroid:within:-101,68,-99,71"; // OR
+        handleGeotileLessThanPrecision(tilePwithinGet("geotile:geo_params.centroid:interval-4", Arrays.asList(pwithin), "3/1/1"), 1, "4/3/3");
+
+        pwithin = "geo_params.centroid:within:-111,68,-109,71";
+        String pwithin2 = "geo_params.centroid:within:-111,68,-99,71"; // AND
+        handleGeotileLessThanPrecision(tilePwithinGet("geotile:geo_params.centroid:interval-4", Arrays.asList(pwithin, pwithin2), "3/1/1"), 1, "4/3/3");
+
+        pwithin = "geo_params.centroid:within:-5,0,0,5";
+        handleGeohashTileDisjointFromPwithin(tilePwithinGet("geotile:geo_params.centroid:interval-4", Arrays.asList(pwithin), "4/3/3"));
 
     }
 
@@ -70,23 +94,27 @@ public void testGeohashTile() throws Exception {
 
     protected abstract void handleGeohashTileLessThanPrecision(ValidatableResponse then, int featuresSize, String geohash) throws Exception;
 
+    protected abstract void handleGeotileGreaterThanPrecision(ValidatableResponse then, int count, String geohash) throws Exception;
+
+    protected abstract void handleGeotileLessThanPrecision(ValidatableResponse then, int featuresSize, String geohash) throws Exception;
+
     protected abstract void handleGeohashTileDisjointFromPwithin(ValidatableResponse then) throws Exception;
 
-    private ValidatableResponse geohashTileGet(Object paramValue, String geohash) {
+    private ValidatableResponse tileGet(Object paramValue, String tile) {
         return given().param("agg", paramValue)
-                .when().get(getGeohashUrlPath("geodata", geohash))
+                .when().get(getTileUrlPath("geodata", tile))
                 .then();
     }
 
-    private ValidatableResponse geohashTilePwithinGet(Object paramValue, List<String> pwithinValues, String geohash) {
+    private ValidatableResponse tilePwithinGet(Object paramValue, List<String> pwithinValues, String tile) {
         RequestSpecification req = given().param("agg", paramValue);
         for (String pwithin : pwithinValues) {
             req = req.param("f", pwithin);
         }
-        return req.when().get(getGeohashUrlPath("geodata", geohash))
+        return req.when().get(getTileUrlPath("geodata", tile))
                 .then();
     }
 
-    protected abstract String getGeohashUrlPath(String collection, String geohash);
+    protected abstract String getTileUrlPath(String collection, String tile);
 
 }
diff --git a/arlas-tests/src/test/java/io/arlas/server/rest/explore/GeoAggregateServiceIT.java b/arlas-tests/src/test/java/io/arlas/server/rest/explore/GeoAggregateServiceIT.java
index d17acf162..c0218ae68 100644
--- a/arlas-tests/src/test/java/io/arlas/server/rest/explore/GeoAggregateServiceIT.java
+++ b/arlas-tests/src/test/java/io/arlas/server/rest/explore/GeoAggregateServiceIT.java
@@ -22,6 +22,7 @@
 import io.arlas.server.model.enumerations.AggregationTypeEnum;
 import io.arlas.server.model.enumerations.CollectionFunction;
 import io.arlas.server.model.request.*;
+import io.arlas.server.utils.GeoTileUtil;
 import io.restassured.response.ValidatableResponse;
 import io.restassured.specification.RequestSpecification;
 import org.hamcrest.Matcher;
@@ -41,8 +42,8 @@ protected String getUrlPath(String collection) {
     }
 
     @Override
-    protected String getGeohashUrlPath(String collection, String geohash) {
-        return getUrlPath(collection) + "/" + geohash;
+    protected String getTileUrlPath(String collection, String tile) {
+        return getUrlPath(collection) + "/" + tile;
     }
 
     @Override
@@ -393,6 +394,21 @@ protected void handleGeohashTileLessThanPrecision(ValidatableResponse then, int
                 .body("features.properties.geohash", everyItem(greaterThanOrEqualTo(geohash)));
     }
 
+    @Override
+    protected void handleGeotileGreaterThanPrecision(ValidatableResponse then, int count, String tile) throws Exception {
+        then.statusCode(200)
+                .body("features.size()", equalTo(1))
+                .body("features[0].properties.count", equalTo(count))
+                .body("features[0].properties.tile", lessThanOrEqualTo(tile));
+    }
+
+    @Override
+    protected void handleGeotileLessThanPrecision(ValidatableResponse then, int featuresSize, String tile) throws Exception {
+        then.statusCode(200)
+                .body("features.size()", equalTo(featuresSize))
+                .body("features.properties.tile", everyItem(greaterThanOrEqualTo(tile)));
+    }
+
     @Override
     protected void handleGeohashTileDisjointFromPwithin(ValidatableResponse then) throws Exception {
         then.statusCode(200)
@@ -442,6 +458,4 @@ protected void handleFlatFormatRequest(ValidatableResponse then, List<String>
                     .body("features.properties", hasItem(hasKey(flattenedItem)));
         });
     }
-
-
 }