# Geo aggregations

Geo aggregations answer "how many, and where": how many cafes are within 1, 5 and 10 km, how
matches spread across a map, or where to center and zoom a map so that it shows them all. They run
over every match, in the same request as the hits, like any other
[facet or metric](facets-and-aggregations.md). The examples use the `places` collection from the
[Geo search quickstart](geo-search.md#quickstart).

| Aggregation | `/v1` | Elasticsearch |
|---|---|---|
| [Grid cells](#grid-aggregations) | facet `type`: `geohash_grid`, `geotile_grid`, `geohex_grid` | the aggregations of the same names |
| [Distance rings](#distance-rings) | facet `type`: `geo_distance` | `geo_distance` |
| [Bounds and centroid](#bounds-and-centroid) | metrics `geo_bounds`, `geo_centroid` | `geo_bounds`, `geo_centroid` |
| [Query buckets](#query-buckets) | facet `type`: `query` with geo queries | `filters` with geo queries |

The geo facets and metrics work with any query `language`. They also work on
[`geo_shape` fields](geo-shapes.md#aggregate-shape-fields), except `geohex_grid` and `geo_distance`.

## Grid aggregations

A grid aggregation counts the matches in each cell of a grid, the building block of heat maps and
clustered map markers. Each bucket can run metrics and nested facets:

::: tabs API
=== WarpSearch
```json
{
  "language": "lucene",
  "query": "*:*",
  "limit": 0,
  "facets": {
    "cells": {
      "type": "geohash_grid", "field": "location", "precision": 5,
      "metrics": {"avg_rating": "avg(rating)"}
    }
  }
}
```

```json
"facets": {
  "cells": {
    "type": "geohash_grid",
    "field": "location",
    "buckets": [
      {"value": "dr5ru", "count": 2, "metrics": {"avg_rating": 4.35}},
      {"value": "dr5rg", "count": 1, "metrics": {"avg_rating": 4.5}},
      {"value": "dr5rs", "count": 1, "metrics": {"avg_rating": 4.4}},
      {"value": "dr5rv", "count": 1, "metrics": {"avg_rating": 4.7}}
    ]
  }
}
```

=== Elasticsearch
```json
{
  "size": 0,
  "aggs": {
    "cells": {
      "geohash_grid": {"field": "location", "precision": 5},
      "aggs": {"avg_rating": {"avg": {"field": "rating"}}}
    }
  }
}
```

```json
"aggregations": {
  "cells": {
    "buckets": [
      {"key": "dr5ru", "doc_count": 2, "avg_rating": {"value": 4.35}},
      {"key": "dr5rg", "doc_count": 1, "avg_rating": {"value": 4.5}},
      {"key": "dr5rs", "doc_count": 1, "avg_rating": {"value": 4.4}},
      {"key": "dr5rv", "doc_count": 1, "avg_rating": {"value": 4.7}}
    ]
  }
}
```
:::

Harbor Coffee and Midtown Market share the Midtown cell `dr5ru`; the other places each have a cell
of their own.

| Grid | `precision` | Default | Bucket key |
|---|---|---|---|
| `geohash_grid` | Geohash length, 1 to 12 | 5 | The geohash, such as `"dr5ru"` |
| `geotile_grid` | Map zoom level, 0 to 29 | 7 | The tile, `"zoom/x/y"` |
| `geohex_grid` | H3 resolution, 0 to 15 | 6 | The H3 index in hex |

| Parameter | Meaning |
|---|---|
| `field` | The `geo_point` (or, except for `geohex_grid`, `geo_shape`) field. |
| `precision` | The grid level, as above. A distance such as `"1km"` is not supported. |
| `bounds` | Optional. Keep only the cells that meet a box: `top_left` and `bottom_right`, `top_right` and `bottom_left`, `top`, `left`, `bottom` and `right`, or `{"wkt": "BBOX (...)"}`. A left edge east of the right one crosses the date line. Each kept cell still counts all of its points. |
| `size` | How many cells to return, the largest counts first, then by key. Default 10000, or your plan's maximum facet size if lower; more answers `422 limit_exceeded`. Elasticsearch's `shard_size` is accepted. |

- A document counts once in each distinct cell its points fall in.
- A grid counts at most 65536 distinct cells. A finer `precision` over a wide area can pass that and
  fail the search; narrow the query or set `bounds`.
- Pick the precision from the map's zoom: `geotile_grid` with the zoom level of the map, or a
  geohash length that gives a few hundred cells on screen.
- To fetch the documents of one cell, use a [`geo_grid` query](geo-points-and-queries.md#grid-cells)
  with the bucket's key.
- `missing` and `script` are not supported.

## Distance rings

A `geo_distance` aggregation counts the matches in distance bands from an origin, like
Elasticsearch's `geo_distance` aggregation:

::: tabs API
=== WarpSearch
```json
{
  "language": "lucene",
  "query": "*:*",
  "limit": 0,
  "facets": {
    "rings": {
      "type": "geo_distance", "field": "location", "origin": "40.7580,-73.9855", "unit": "km",
      "ranges": [{"to": 1}, {"from": 1, "to": 5}, {"from": 5}],
      "facets": {"by_category": {"type": "terms", "field": "category"}}
    }
  }
}
```

The buckets are `*-1.0` (count 2: Harbor Coffee and Midtown Market), `1.0-5.0` (count 1: Hudson
Espresso) and `5.0-*` (count 2: Astoria Bakery and Bridge Roasters). Each `/v1` bucket has the key
as `value`, its `count`, its `from` and `to`, and its nested `facets`.

=== Elasticsearch
```json
{
  "size": 0,
  "aggs": {
    "rings": {
      "geo_distance": {
        "field": "location", "origin": "40.7580,-73.9855", "unit": "km",
        "ranges": [{"to": 1}, {"from": 1, "to": 5}, {"from": 5}]
      },
      "aggs": {"by_category": {"terms": {"field": "category"}}}
    }
  }
}
```

Buckets come back as `{"key": "*-1.0", "to": 1.0, "doc_count": 2, ...}`, in request order.
:::

| Parameter | Meaning |
|---|---|
| `field` | The `geo_point` field. |
| `origin` | The point, in any query point form. |
| `unit` | `m` (default), `km`, `mi`, `yd`, `ft`, `in`, `cm`, `mm` or `nmi`, for `from` and `to`. |
| `ranges` | A list of `{"from", "to", "key"}`. `from` is included and `to` is not; either can be left open. At most your plan's maximum facet size. |
| `keyed` | `true` returns the buckets as an object by key instead of a list. |

- Ranges may overlap, and a document counts once in each range that holds one of its points.
- Keys default to Elasticsearch's `from-to` text, with `*` for an open end. Set `key` to name a
  range.
- Elasticsearch's `distance_type: plane` is accepted with a warning.

## Bounds and centroid

`geo_bounds` returns the box around the matching points, and `geo_centroid` their average location
and how many there are. Together they fit a map to the results:

::: tabs API
=== WarpSearch
```json
{
  "language": "lucene",
  "query": "*:*",
  "limit": 0,
  "metrics": {
    "box": {"func": "geo_bounds", "field": "location"},
    "middle": "geo_centroid(location)"
  }
}
```

```json
"metrics": {
  "box": {"bounds": {"top_left": {"lat": 40.7644, "lon": -74.0324},
                     "bottom_right": {"lat": 40.7033, "lon": -73.9235}}},
  "middle": {"location": {"lat": 40.7447, "lon": -73.9816}, "count": 5}
}
```

=== Elasticsearch
```json
{
  "size": 0,
  "aggs": {
    "box": {"geo_bounds": {"field": "location"}},
    "middle": {"geo_centroid": {"field": "location"}}
  }
}
```

The results have the same shapes as the `/v1` metrics, under `aggregations`.
:::

(Coordinates rounded here.)

- `geo_bounds` takes `wrap_longitude` (default `true`): when the matches straddle the date line, it
  returns the narrower box across it, with `top_left.lon` greater than `bottom_right.lon`.
- With no matching points, `geo_bounds` has no `bounds`, and `geo_centroid` has `count` 0 and no
  `location`.
- `count` is the number of points, so a multi-valued field counts each of its points.
- Both work nested in any facet bucket, such as one centroid per grid cell for map markers placed
  where the documents are rather than at the cell's center.
- Elasticsearch's `geo_line` is not supported.

## Query buckets

A `query` facet with one geo query per bucket counts the matches in each circle or box you name. It
is available today, and suits a handful of fixed areas or bands:

::: tabs API
=== WarpSearch
```json
{
  "language": "es",
  "query": {"match": {"description": "coffee"}},
  "limit": 0,
  "facets": {
    "distance": {
      "type": "query",
      "buckets": {
        "within_1km":  {"geo_distance": {"distance": "1km",  "location": "40.7580,-73.9855"}},
        "within_5km":  {"geo_distance": {"distance": "5km",  "location": "40.7580,-73.9855"}},
        "within_10km": {"geo_distance": {"distance": "10km", "location": "40.7580,-73.9855"}}
      },
      "metrics": {"avg_rating": "avg(rating)"}
    }
  }
}
```

With `"language": "es"`, the bucket queries are Query DSL objects. Each bucket in the response has
the bucket name as its `value`, a `count`, and its `metrics`.

=== Elasticsearch
A `filters` aggregation with a geo filter per bucket:

```json
{
  "query": {"match": {"description": "coffee"}},
  "size": 0,
  "aggs": {
    "distance": {
      "filters": {"filters": {
        "within_1km":  {"geo_distance": {"distance": "1km",  "location": "40.7580,-73.9855"}},
        "within_5km":  {"geo_distance": {"distance": "5km",  "location": "40.7580,-73.9855"}},
        "within_10km": {"geo_distance": {"distance": "10km", "location": "40.7580,-73.9855"}}
      }},
      "aggs": {"avg_rating": {"avg": {"field": "rating"}}}
    }
  }
}
```
:::

These circles overlap: a place 0.5 km away counts in all three. For rings that do not overlap,
subtract the inner circle with `must_not`, or use [distance rings](#distance-rings) once they are
live:

```json
"from_1_to_5km": {"bool": {
  "filter":   [{"geo_distance": {"distance": "5km", "location": "40.7580,-73.9855"}}],
  "must_not": [{"geo_distance": {"distance": "1km", "location": "40.7580,-73.9855"}}]
}}
```

The same pattern counts named areas, with one `geo_bounding_box` (or, once live, `geo_polygon` or
`geo_shape`) per bucket.
