Spatial queries
Reference for the geo filter operators: bounding box, proximity, containment, intersection, snap-to-line, and distance sort.
#Spatial queries
Seven spatial operators filter records by location. They work anywhere ordinary filter operators work — the filter query parameter on the records API and the filter option in the SDK — and apply to any of the seven geo field types, with the multi-part caveats noted below.
Each query runs in two stages: a coarse scan against the S2 cell index inside D1, then a precise geometry test on the candidates. You get exact results at index speed.
| Operator | Matches records that | Operand |
|---|---|---|
_geo_bbox |
Fall inside a bounding box | [west, south, east, north] |
_geo_near |
Come within a radius of a point | { lat, lng, radius } (radius in metres) |
_geo_within |
Lie inside a polygon | GeoJSON Polygon |
_geo_intersects |
Have geometry that intersects a query geometry | Any GeoJSON geometry |
_geo_contains |
Lie entirely inside a query geometry | Any GeoJSON geometry |
_geo_dwithin |
Lie within a distance of a line (snap to route) | { geometry | ref, distance } (distance in metres) |
_geo_distance |
Are candidates for distance sorting | { lat, lng, radius? } (radius in metres) |
#Multi-part geometries
A MultiPoint, MultiLineString or MultiPolygon value is a list of parts — whole polygons, whole lines. Every operator accepts one, but they do not all resolve a record the same way, and the difference shows up on multi-part values more than on any other data:
| Group | Operators | Against a multi-part record |
|---|---|---|
| Whole geometry — the exact test runs against every part | _geo_near, _geo_intersects, _geo_contains, _geo_distance |
Matches on any part, and _distance is measured to the nearest of them |
| Extent — the record's stored bounding box | _geo_bbox |
Matches through a box that covers every part |
| One stored point — the record is resolved to a single coordinate | _geo_within, _geo_dwithin |
Judged by that one point rather than by the parts |
The last group is the trap, and it is permanent rather than pending. Every non-point geometry stores two points — a Centroid and a Representative Point (see Geo fields and ADR 0021) — and the two operators in that group use different ones:
_geo_withinuses the Representative Point, which is guaranteed to lie inside one of the parts. So an archipelago is "within" a polygon covering the island that point falls in, and outside one covering only its other islands._geo_dwithinsnaps the Centroid to its corridor, and a multi-part Centroid can fall outside every part — the centroid of an island group is usually open water. A corridor threading between the islands can match the group; one running along a single island may not.
That is the cost of resolving a shape to a coordinate, not a defect: ADR 0021 records that no choice of stored point avoids it.
A geometry that crosses the antimeridian (180°) cannot be stored at all — a single part whose coordinates jump the line is refused on write. Data split at 180° into parts, the way RFC 7946 directs, is stored and queried correctly: its bounding box is taken on the shortest arc of longitude containing every part, so a split Fiji is the 5° box it occupies rather than a 360° one, and _geo_bbox reads that convention. See Geo fields and ADR 0022.
#_geo_bbox
Returns records whose centroid falls inside the box, or whose stored bounding box overlaps it — so lines and polygons are returned even when their centroid sits outside the viewport. The operand is [west, south, east, north] or { west, south, east, north }.
Both halves of that test are served by an index, so a viewport query does not read the whole collection. See Geo fields for the columns involved.
#HTTP
GET /api/v1/collections/campsites/records?filter={"location":{"_geo_bbox":[150.1,-34.0,151.5,-33.2]}}
Authorization: Bearer sk-your-api-key
#SDK
import { CollectionClient } from '@emuview/sdk';
const inView = await sdk.collection('campsites').list({
filter: CollectionClient.geoBbox('location', 150.1, -34.0, 151.5, -33.2)
});
#_geo_near
Returns records within radius metres of a point. The radius must be a positive number.
Distance is measured to the nearest point of the record's geometry, not to a single point standing in for it. A point record is measured to itself; a line to its nearest position along the route; an area to its nearest edge, and to nought when the query point is inside it. So "national parks within 10 km", asked from a car park inside one, returns that park.
Each matching record carries a _distance in kilometres — the same measure that admitted it, so a result can never be nearer or farther than the radius that selected it.
#HTTP
GET /api/v1/collections/campsites/records?filter={"location":{"_geo_near":{"lat":-33.8688,"lng":151.2093,"radius":5000}}}
Authorization: Bearer sk-your-api-key
#SDK
import { CollectionClient } from '@emuview/sdk';
const nearby = await sdk.collection('campsites').list({
filter: CollectionClient.geoNear('location', -33.8688, 151.2093, 5000) // 5 km
});
#_geo_within
Returns records that lie inside a GeoJSON Polygon. The operand is the polygon itself.
A record is represented by a single interior point — a point guaranteed to be inside its own geometry, even for a concave shape or a multi-part one whose balance point falls in open water between its parts. So an archipelago is "within" a polygon that holds the island that point falls in, and a crescent-shaped reserve is not "within" one that only covers the bay it curves around.
const inPark = await sdk.collection('campsites').list({
filter: {
location: {
_geo_within: {
type: 'Polygon',
coordinates: [
[
[150.2, -33.8],
[150.6, -33.8],
[150.6, -33.4],
[150.2, -33.4],
[150.2, -33.8]
]
]
}
}
}
});
#_geo_intersects
Returns records whose geometry intersects the query geometry — crossing edges or containment in either direction. Works for every geometry type on both sides, the multi-part ones included: two geometries intersect when any part of one meets any part of the other. Line, polygon and multi-part records are matched through their stored covering cells, so any part of their extent can match.
// Which trails cross this proposed road?
const crossing = await sdk.collection('trails').list({
filter: {
route: {
_geo_intersects: {
type: 'LineString',
coordinates: [
[150.3, -33.7],
[150.45, -33.55]
]
}
}
}
});
#_geo_contains
Returns records that lie entirely inside the query geometry. The operand is the container and the records are what it holds.
The relation runs the opposite way to the operator's name, which is a known mismatch rather than a deliberate design:
_geo_containsreads as "records that contain the operand" and does the reverse. It is described here as it behaves. Expect the name, not the behaviour, to change.
// Which trails are completely inside this park boundary?
const inside = await sdk.collection('trails').list({
filter: {
route: {
_geo_contains: {
type: 'Polygon',
coordinates: [
[
[150.2, -33.8],
[150.6, -33.8],
[150.6, -33.4],
[150.2, -33.4],
[150.2, -33.8]
]
]
}
}
}
});
Containment is total and per part: every part of a record's geometry must fit inside a single part of the operand. A track that runs from one island of an archipelago to another is not contained by that archipelago, even though both of its ends are on land.
For the reverse question — "which zone is this location in?" — use _geo_intersects with the point as the operand.
#_geo_dwithin (snap to line)
Returns records within distance metres of a LineString — a corridor around a route rather than a circle around a point. This is the "find everything along a hiking route" query: pass a track and a distance, get back the points (photos, campsites, pubs…) that fall within that band of the line.
The corridor is indexed segment by segment, so a route running diagonally across a region scans only the cells its path actually touches — not the whole bounding box of the line. Supply the line inline as geometry, or reference a line stored on another record with ref: { collection, recordId, field } (the referenced record is read with your normal permissions).
Each matching record is enriched with snap measures (all distances in metres):
| Field | Meaning |
|---|---|
_distance |
Closest distance from the record to the line |
_distance_along |
Distance from the start of the line to the snapped point |
_line_locate |
Fraction along the line at the snapped point (0 = start, 1 = end) |
_snapped |
{ lat, lng } of the closest point on the line |
Sort by _distance_along to walk results in route order, or by _distance for nearest-to-the-line first.
#HTTP
GET /api/v1/collections/photos/records?sort=_distance_along&filter={"location":{"_geo_dwithin":{"geometry":{"type":"LineString","coordinates":[[151.20,-33.86],[151.22,-33.84]]},"distance":200}}}
Authorization: Bearer sk-your-api-key
#SDK
import { CollectionClient } from '@emuview/sdk';
// Inline route
const alongRoute = await sdk.collection('photos').list({
filter: CollectionClient.geoDWithin('location', route, 200), // within 200 m
sortBy: '_distance_along',
limit: 100
});
// …or reference a stored route record
const nearTrack = await sdk.collection('pubs').list({
filter: CollectionClient.geoDWithinRef(
'location',
{ collection: 'routes', recordId: 'rt_123', field: 'track' },
500
),
sortBy: '_distance_along'
});
The distance is capped at 50 km and the input line at 10,000 vertices. A line that crosses the antimeridian is refused rather than answered: the per-segment covering and the snap projection both assume longitudes do not wrap, so a crossing line would return quietly wrong distances. Split it into two lines either side of 180°. Like the other geometry operators, the exact snap runs on the candidate page returned by the index scan, so pass a generous limit (or page through) when a route is expected to have many matches.
#_geo_distance and distance sorting
_geo_distance selects candidate records around a point without a hard radius cap, so you can sort by distance. Pass sort=_geo_distance to order results nearest-first; each returned record gains a _distance value in kilometres, measured to the nearest point of its geometry exactly as _geo_near measures. The optional radius (metres) widens or narrows the candidate search; the default candidate radius is 50 km.
GET /api/v1/collections/campsites/records?filter={"location":{"_geo_distance":{"lat":-33.8688,"lng":151.2093}}}&sort=_geo_distance
Authorization: Bearer sk-your-api-key
Distance sorting also works with _geo_near when you want both a radius cap and nearest-first ordering.
#Rejected queries
A spatial operator that cannot accept its operand answers 422 invalid_query carrying a message that names the operator and what was wrong with it — a malformed geometry, a corridor wider than the cap, a line crossing the antimeridian. It is never a 500, and the filter is never silently dropped: a dropped spatial clause would widen the result to the whole collection and report success.
One case is a 422 even though the operand is fine. A role permission whose item_filter uses a geo operator is refused, because a row predicate is composed into WHERE clauses and subqueries where the exact-geometry pass cannot run — enforcing only the coarse cell index would hand back a wider set of rows than the rule describes. Express the restriction with a non-geo field, or scope the collection rather than the row.
#Limits and caching
| Setting | Default | Description |
|---|---|---|
map.max_features_per_request |
5000 |
Hard limit on features returned by a single geo query |
map.cache_ttl_seconds |
30 |
How long geo query results for public (unauthenticated) users are cached. 0 disables the cache |
Both are configurable in Settings → Map. Before running exact geometry tests, bounding boxes are compared first, so rows that can't possibly match are skipped cheaply.
#Hook events
Spatial indexing and querying dispatch automation hook events:
| Event | Fires |
|---|---|
geo.index.before |
Before a record's geo fields are indexed on create or update (blocking) |
geo.index.after |
After indexing completes (fire-and-forget) |
geo.query.before |
Before the precise post-filter stage of a spatial query (blocking) |
geo.query.after |
After the post-filter stage, with before/after result counts (fire-and-forget) |