# Geo shapes

A `geo_point` field holds locations. A `geo_shape` field holds geometry: delivery zones, service
areas, neighborhoods, parcels, routes, or any mix of polygons, lines and points. Queries then ask
how each document's shape relates to a query shape: does this zone cover the customer's address,
does this route cross the closure, which parcels lie entirely inside the district.

The examples add a delivery area to some of the places in the
[Geo search quickstart](geo-search.md#quickstart).

## Define a geo_shape field

::: tabs API
=== WarpSearch
```bash
curl -X PATCH https://api.warpsearch.io/v1/collections/places/schema \
  -H "Authorization: Bearer $WARPSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"fields": {"delivery_area": "geo_shape"}}'
```

With settings, the definition is an object with `params`:

```json
{"fields": {"delivery_area": {"type": "geo_shape", "params": {"max_cells": 64, "err_pct": 0.01}}}}
```

=== Elasticsearch
```bash
curl -X PUT https://api.warpsearch.io/compat/es/places/_mapping \
  -H "Authorization: Bearer $WARPSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"properties": {"delivery_area": {"type": "geo_shape"}}}'
```

The mapping accepts `orientation`, `ignore_malformed` and `distance_error_pct` (the `err_pct`
setting below). `index`, `doc_values` and `ignore_z_value` are accepted when `true`, and `coerce`
when `false`. The legacy prefix-tree settings (`tree`, `strategy`, `precision`, `tree_levels`,
`points_only`), `coerce: true`, `copy_to` and multi-fields are rejected with a message naming the
setting.
:::

| Setting | Range | Default | Effect |
|---|---|---|---|
| `max_cells` | 1 to 1024 | 8 to 64, growing with the shape's vertex count | The most grid cells used to index one shape. |
| `max_level` | 2 to 32 | 24 | The finest grid level used to index a shape. Odd values round down. |
| `err_pct` | 0 to 0.5 | 0.025 | Stop refining a shape's cells once they are this fraction of its bounding box. |
| `orientation` | `right` (or `ccw`, `counterclockwise`), `left` (or `cw`, `clockwise`) | `right` | The winding of polygon outer rings in WKT values, used to decide whether a wide polygon crosses the date line. |
| `pyramid` | `true`, `false` | `true` | Also index each cell's ancestors, which speeds up queries with large query shapes at the cost of more index terms. |

Beside `type` and `params`, a `geo_shape` definition takes `ignore_malformed` and `parent`.
Other options, such as `index`, are rejected. The settings decide how shapes are indexed, so changing
them on an existing field is refused with `409 incompatible_schema`; add a new field instead. See
[Accuracy and index size](#accuracy-and-index-size) for how to choose them.

## Send shapes

A shape value is a GeoJSON object, a WKT string (or GeoJSON text in a string), or a point in the
`[lon, lat]` or `{"lat": ..., "lon": ...}` form. Positions are longitude first.

```bash
curl -X POST "https://api.warpsearch.io/v1/collections/places/documents?commit=true" \
  -H "Authorization: Bearer $WARPSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[
    {"id": "p1", "name": "Harbor Coffee", "description": "Espresso, pour-over and pastries",
     "category": "cafe", "city": "Manhattan", "rating": 4.6, "location": [-73.9851, 40.7589],
     "delivery_area": {"type": "Polygon", "coordinates": [[[-74.00, 40.745], [-73.97, 40.745],
       [-73.97, 40.775], [-74.00, 40.775], [-74.00, 40.745]]]}},
    {"id": "p3", "name": "Midtown Market", "description": "Groceries, deli and a coffee bar",
     "category": "grocery", "city": "Manhattan", "rating": 4.1, "location": [-73.9772, 40.7527],
     "delivery_area": "POLYGON ((-73.99 40.74, -73.96 40.74, -73.96 40.765, -73.99 40.765, -73.99 40.74))"},
    {"id": "p5", "name": "Hudson Espresso", "description": "Espresso bar by the ferry",
     "category": "cafe", "city": "Hoboken", "rating": 4.5, "location": [-74.0324, 40.7440],
     "delivery_area": "POLYGON ((-74.05 40.73, -74.01 40.73, -74.01 40.76, -74.05 40.76, -74.05 40.73))"}
  ]'
```

A write replaces the whole document, so the example sends every field again.

| Shape | WKT | GeoJSON `type` |
|---|---|---|
| Point, several points | `POINT`, `MULTIPOINT` | `Point`, `MultiPoint` |
| Line, several lines | `LINESTRING`, `MULTILINESTRING` | `LineString`, `MultiLineString` |
| Polygon with holes, several polygons | `POLYGON`, `MULTIPOLYGON` | `Polygon`, `MultiPolygon` |
| Box | `BBOX (minLon, maxLon, maxLat, minLat)` | `envelope`, `"coordinates": [[minLon, maxLat], [maxLon, minLat]]` |
| Mixed | `GEOMETRYCOLLECTION` | `GeometryCollection` |

- Rings must be closed (the last position repeats the first) and have a non-zero area.
  Coordinates outside -180 to 180 and -90 to 90 are rejected.
- Circles are refused as values; index a polygon instead.
- An empty shape (`POINT EMPTY`, an empty collection) means the document has no value.
- A value that cannot be read fails the document, or with `ignore_malformed: true` the field is
  skipped and the rest of the document is indexed.
- A GeoJSON polygon is read with the right-hand rule unless the object carries its own
  `"orientation"`; WKT polygons follow the field's `orientation`. A polygon that spans more than
  180 degrees of longitude and winds against the orientation is read as crossing the date line,
  as Elasticsearch does.
- Edges are straight lines in latitude and longitude, not great circles.

Name the field in `fields` (or `_source`) to get the shape back as GeoJSON. The coordinates are the
stored grid positions, so they can differ from the input in about the eighth decimal place.

## Query shape fields

`geo_shape` relates each document's shape to a query shape. Which places deliver to an address
near Bryant Park (40.7520, -73.9850)?

::: tabs API
=== WarpSearch
```json
{
  "language": "es",
  "query": {"geo_shape": {"delivery_area": {"shape": "POINT (-73.9850 40.7520)", "relation": "intersects"}}},
  "fields": ["name"]
}
```

=== Elasticsearch
```json
{
  "query": {"bool": {"filter": [{"geo_shape": {"delivery_area": {
    "shape": {"type": "Point", "coordinates": [-73.9850, 40.7520]},
    "relation": "intersects"
  }}}]}},
  "_source": ["name"]
}
```
:::

This matches Harbor Coffee (`p1`) and Midtown Market (`p3`): the address is inside both of their
delivery areas, and outside Hudson Espresso's.

| Relation | Matches documents whose shape |
|---|---|
| `intersects` (default) | Shares at least one point with the query shape. |
| `disjoint` | Shares no point with it. Documents without the field do not match. |
| `within` | Lies inside the query shape: each part of the document's shape inside one part of the query shape. |
| `contains` | Contains the query shape, read the same way: each part of the query shape inside one part of the document's shape. |

- `within` and `contains` compare part by part, as Lucene does: a polygon covered only by the union
  of two query polygons is not within the query.
- Query shapes can be any of the shapes above, and circles: `CIRCLE (lon lat radiusMeters)` or
  `{"type": "circle", "coordinates": [lon, lat], "radius": "2km"}`. On a shape field, a circle is
  matched as the 64-sided polygon inscribed in it.
- A query shape has at most 10000 vertices; more answers `422 limit_exceeded`.
- Relations are decided exactly on the stored geometry: the index narrows the candidates and every
  remaining candidate is checked against the shape, so the settings change speed and size, not
  results.
- `indexed_shape` (a shape stored in another document) is not supported; send the shape inline.

The point queries work on shape fields too, as `intersects` queries: `geo_distance` matches shapes
that come within the radius, `geo_bounding_box` shapes that meet the box, and `geo_polygon` shapes
that meet the polygon. Areas within 1 km of Times Square:

```json
{
  "language": "es",
  "query": {"geo_distance": {"distance": "1km", "delivery_area": "40.7580,-73.9855"}}
}
```

A [`geo_grid` query](geo-points-and-queries.md#grid-cells) with a `geohash` or `geotile` key
matches the shapes that meet the cell. Cells include their edges, so a shape that touches a cell
edge matches both cells, the same documents the shape grid aggregation counts in that bucket.
`geohex` cells are rejected on shape fields with `400`.

Not supported on shape fields: sorting by distance, `geo_nearest` and `distance_feature`. For
those, keep a representative point, such as a zone's center, in a `geo_point` field beside the
shape.

## Aggregate shape fields

The grid aggregations `geohash_grid` and `geotile_grid` and the `geo_bounds` and `geo_centroid`
metrics work on shape fields, in the same request forms as on points (see
[Geo aggregations](geo-aggregations.md)):

::: tabs API
=== WarpSearch
```json
{
  "language": "lucene",
  "query": "*:*",
  "limit": 0,
  "facets": {"tiles": {"type": "geotile_grid", "field": "delivery_area", "precision": 14}},
  "metrics": {"coverage": {"func": "geo_bounds", "field": "delivery_area"}}
}
```

=== Elasticsearch
```json
{
  "size": 0,
  "aggs": {
    "tiles": {"geotile_grid": {"field": "delivery_area", "precision": 14}},
    "coverage": {"geo_bounds": {"field": "delivery_area"}}
  }
}
```
:::

- A document counts once in every cell its shape meets. Cells include their edges, so a shape that
  touches a cell edge counts in both cells.
- A shape that meets more than 65536 cells at the requested precision fails the search. Large
  shapes need coarse precisions.
- `geo_bounds` is the box around all matching shapes.
- `geo_centroid` follows Elasticsearch's shape centroid: polygons weighted by area, lines by length,
  points by count, and only the highest dimension present counts.
- `geohex_grid` and `geo_distance` rings are rejected on shape fields with `400`, as are `geohex`
  cells in a `geo_grid` query.

## Accuracy and index size

A shape is indexed as a set of grid cells that cover it, plus its exact geometry. A query first
uses the cells to find candidates, accepts the documents whose cells settle the answer, and checks
the rest against the exact geometry. Results are the same whatever the settings; the settings
trade index size against how many candidates need that check.

| To | Change | Cost |
|---|---|---|
| Make queries check fewer candidates | Raise `max_cells`, lower `err_pct`, raise `max_level` | More index terms per shape: a larger index and slower writes |
| Shrink the index for many small or simple shapes | Lower `max_cells`, raise `err_pct` | More candidates to check at query time |
| Speed up queries with large query shapes (a state, a country) | Keep `pyramid: true` (the default) | More terms per shape |
| Save index space when query shapes are small | `pyramid: false` | Large query shapes read more terms |

The defaults suit most data. Change them when a profile of real queries shows many candidates being
checked, or when index size matters more than query time. Very detailed shapes, such as coastlines
with tens of thousands of vertices, cost more to check: simplify them before indexing when exact
borders are not needed.
