Files
2026-05-27 23:29:53 +01:00

218 lines
6.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OpenSpec Change: enterprise-geo
- **Change ID**: enterprise-geo
- **Package**: `@suaveplan/geo`
- **Category**: universal
- **Target path**: `universal/geo/geo`
- **Status**: IN PROGRESS
- **Date**: 2026-04-24
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/geo.md`
---
## Summary
Deliver `@suaveplan/geo` on the genesis branch from scratch at enterprise quality. The package provides geolocation and geospatial utilities: WGS84 coordinate types, Haversine / Vincenty distance calculation, bearing and midpoint computation, bounding-box construction and intersection, GeoJSON feature/geometry construction helpers, point-in-polygon (ray-casting), polygon area calculation, clustering helpers (k-means, grid), geocoding adapter interface with in-memory cache, reverse-geocoding adapter interface, and elevation lookup interface. The main-branch version sat at 99.38% coverage. Genesis adds structured errors, Zod schemas, OTel instrumentation, and property-based tests.
---
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/error` (`workspace:^`) | `GeoError`; `GEO.*` error codes |
| `@suaveplan/types` (`workspace:^`) | `LoggerLike`, `Clock` |
| `@suaveplan/retry` (`workspace:^`) | Retry for geocoding adapter network calls |
| `zod` (`catalog:forms`) | Schema validation |
| `@opentelemetry/api` (`catalog:observability`) | Spans + counters |
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check` (all `catalog:testing`).
---
## Public API surface
### Coordinate types
```ts
interface LatLng {
lat: number // degrees, -90 to 90
lng: number // degrees, -180 to 180
}
interface LatLngAlt extends LatLng {
alt?: number // metres above sea level
}
interface BoundingBox {
north: number
south: number
east: number
west: number
}
```
### Distance and geometry
```ts
function haversineDistance(a: LatLng, b: LatLng): number // metres
function vincentyDistance(a: LatLng, b: LatLng): number // metres (more accurate)
function bearing(from: LatLng, to: LatLng): number // degrees 0–360
function midpoint(a: LatLng, b: LatLng): LatLng
function destination(from: LatLng, bearing: number, distance: number): LatLng
function boundingBoxFromPoints(points: LatLng[]): BoundingBox
function expandBoundingBox(box: BoundingBox, metres: number): BoundingBox
function boundingBoxesIntersect(a: BoundingBox, b: BoundingBox): boolean
function pointInBoundingBox(point: LatLng, box: BoundingBox): boolean
```
### GeoJSON helpers
```ts
function toGeoJsonPoint(point: LatLng): GeoJSON.Point
function toGeoJsonLineString(points: LatLng[]): GeoJSON.LineString
function toGeoJsonPolygon(rings: LatLng[][]): GeoJSON.Polygon
function fromGeoJsonPoint(point: GeoJSON.Point): LatLng
function polygonArea(ring: LatLng[]): number // square metres (shoelace + WGS84 correction)
function pointInPolygon(point: LatLng, ring: LatLng[]): boolean // ray-casting
```
### Clustering
```ts
function gridCluster(points: LatLng[], cellSizeMetres: number): LatLng[][]
function kMeansCluster(points: LatLng[], k: number, maxIterations?: number): LatLng[][]
```
### Adapter interfaces
```ts
interface GeocodeAdapter {
geocode(address: string, options?: GeocodeOptions): Promise<GeocodeResult[]>
reverseGeocode(point: LatLng, options?: ReverseGeocodeOptions): Promise<GeocodeResult>
}
interface GeocodeResult {
address: string
point: LatLng
confidence: number // 0–1
components: AddressComponents
}
function createCachedGeocodeAdapter(
inner: GeocodeAdapter,
options?: CacheOptions
): GeocodeAdapter
```
### Error codes
```ts
type GeoErrorCode =
| 'GEO_INVALID_COORDINATE'
| 'GEO_INVALID_POLYGON'
| 'GEO_GEOCODE_FAILED'
| 'GEO_REVERSE_GEOCODE_FAILED'
| 'GEO_CLUSTER_FAILED'
| 'GEO_ABORTED'
```
---
## Genesis additions
1. **`GeoError`** extends `SuaveplanError` with six stable codes.
2. **Zod schemas** for `LatLng`, `LatLngAlt`, `BoundingBox`, `GeocodeResult`, `AddressComponents`, `GeocodeOptions`.
3. **Vincenty distance** — more accurate than Haversine for long distances; falls back to Haversine for antipodal/degenerate inputs.
4. **Cached geocode adapter** — wraps any `GeocodeAdapter` with an in-memory LRU cache (default 1 000 entries, TTL 1 hour).
5. **OTel instrumentation** — span `geo.geocode` with attribute `geo.provider`; counter `geo.geocode_total`; histogram `geo.geocode_duration_ms`.
6. **Property-based tests** — distance symmetry: `haversineDistance(a,b) === haversineDistance(b,a)`; bounding-box expansion monotonicity; point-in-polygon edge cases (point on edge, winding direction).
---
## File layout
```text
universal/geo/geo/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
error-codes.ts
constants/
index.ts # EARTH_RADIUS_M, DEG_TO_RAD
types/
common.ts
common.md
distance/
haversine/
haversine.ts
haversine.md
haversine.test.ts
index.ts
vincenty/
vincenty.ts
vincenty.md
vincenty.test.ts
index.ts
geometry/
bounding-box/
bounding-box.ts
bounding-box.md
bounding-box.test.ts
index.ts
polygon/
polygon.ts
polygon.md
polygon.test.ts
index.ts
geojson/
geojson.ts
geojson.md
geojson.test.ts
index.ts
clustering/
grid-cluster/
grid-cluster.ts
grid-cluster.md
grid-cluster.test.ts
index.ts
kmeans-cluster/
kmeans-cluster.ts
kmeans-cluster.md
kmeans-cluster.test.ts
index.ts
geocoding/
geocode-adapter/
geocode-adapter.ts
geocode-adapter.md
geocode-adapter.types.ts
geocode-adapter.test.ts
index.ts
cached-geocode/
cached-geocode.ts
cached-geocode.md
cached-geocode.test.ts
index.ts
__benchmarks__/
geo.bench.ts
tests/
integration.test.ts
property.test.ts
```
---
## Impact
**Downstream consumers**: `@suaveplan/ports`, `@suaveplan/tides`, `@suaveplan/heightmap-tiles`, `@suaveplan/osm-buildings`.
**Workspace dep prerequisites**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/retry`.