first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
# @suaveplan/geo-earth - Earth-specific geo utilities: EARTH PlanetConfig and earthSurface, decimal/DMS coordinate parse + format, geohash encode/decode, and an in-memory geocoding provider.
## Summary
Earth configuration and legacy geo utilities.
<!-- begin:auto-docs -->
## Documentation tree
- [coordinates](./src/coordinates/coordinates.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [coordinates](./src/coordinates/coordinates.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [earth-config](./src/earth-config/earth-config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [earth-config](./src/earth-config/earth-config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [geocoding](./src/geocoding/geocoding.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [geocoding](./src/geocoding/geocoding.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [geohash](./src/geohash/geohash.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [geohash](./src/geohash/geohash.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [coordinates](./src/coordinates/coordinates.md) — <!-- begin:auto-docs --> _(no submodules)_- [earth-config](./src/earth-config/earth-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [earth-config](./src/earth-config/earth-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [geocoding](./src/geocoding/geocoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [geocoding](./src/geocoding/geocoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [geohash](./src/geohash/geohash.md) — <!-- begin:auto-docs --> _(no submodules)_- [geohash](./src/geohash/geohash.md) — <!-- begin:auto-docs --> _(no submodules)_- [coordinates](./src/coordinates/coordinates.md) — <!-- begin:auto-docs --> _(no submodules)_- [earth-config](./src/earth-config/earth-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [earth-config](./src/earth-config/earth-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [geocoding](./src/geocoding/geocoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [geocoding](./src/geocoding/geocoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [geohash](./src/geohash/geohash.md) — <!-- begin:auto-docs --> _(no submodules)_- [geohash](./src/geohash/geohash.md) — <!-- begin:auto-docs --> _(no submodules)_- [coordinates](./src/coordinates/coordinates.md) — <!-- begin:auto-docs --> _(no submodules)_- [earth-config](./src/earth-config/earth-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [earth-config](./src/earth-config/earth-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [geocoding](./src/geocoding/geocoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [geocoding](./src/geocoding/geocoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [geohash](./src/geohash/geohash.md) — <!-- begin:auto-docs --> _(no submodules)_- [geohash](./src/geohash/geohash.md) — <!-- begin:auto-docs --> _(no submodules)_- [coordinates](./src/coordinates/coordinates.md) — <!-- begin:auto-docs --> _(no submodules)_- [earth-config](./src/earth-config/earth-config.md) — <!-- begin:auto-docs --> _(no submodules)_ - [earth-config](./src/earth-config/earth-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [geocoding](./src/geocoding/geocoding.md) — <!-- begin:auto-docs --> _(no submodules)_ - [geocoding](./src/geocoding/geocoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [geohash](./src/geohash/geohash.md) — <!-- begin:auto-docs --> _(no submodules)_ - [geohash](./src/geohash/geohash.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/geo-earth
```
## Quick start
```ts
import { EARTH, earthSurface, encodeGeohash } from "@suaveplan/geo-earth";
const hash = encodeGeohash({ latitude: 51.5, longitude: -0.1 });
```

View File

@@ -0,0 +1,63 @@
# coordinates
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tolerant string parser and human-readable formatter for latitude/longitude pairs on Earth. The parser accepts comma- or whitespace-separated decimal degrees (returning `null` on malformed input rather than throwing), and the formatter emits either fixed-precision decimal degrees or degrees/minutes/seconds (DMS) with hemispheric `N`/`S`/`E`/`W` suffixes.
Together these cover the two everyday surfaces a geo application touches: ingesting user-typed coordinates from forms, search bars, or imported CSVs, and rendering coordinates back to operators in a stable, copy-pasteable form. Both helpers are pure, allocation-light, and carry no dependency on the rest of the package.
## Features
- `parseCoordinate(input)` accepts `"51.5, -0.1"`, `"51.5 -0.1"`, or any mix of comma + whitespace.
- Returns `null` for unparseable input — never throws.
- `formatCoordinate(coord, format)` supports `"decimal"` (default, six decimal places per axis) or `"dms"`.
- DMS output uses Unicode `°`, `'`, `"` and a hemisphere letter derived from the sign.
- Pure functions; no I/O, no clock, no RNG; safe in any tier (universal).
## Usage: Basic Example
```ts
import { formatCoordinate, parseCoordinate } from "@suaveplan/geo-earth";
const parsed = parseCoordinate("51.5, -0.1");
// { latitude: 51.5, longitude: -0.1 }
const decimal = formatCoordinate({ latitude: 51.5, longitude: -0.1 });
// "51.500000, -0.100000"
```
## Usage: Advanced Example
```ts
import { formatCoordinate, parseCoordinate } from "@suaveplan/geo-earth";
function normalize(raw: string): string | null {
const parsed = parseCoordinate(raw);
if (parsed === null) return null;
return formatCoordinate(parsed, "dms");
}
normalize(" -33.8688\t151.2093 ");
// "33°52'7.68\"S, 151°12'33.48\"E"
normalize("not a coord"); // null
```
## API Reference
- `parseCoordinate(input: string): { latitude: number; longitude: number } | null`
- Splits on `[,\s]+`. Requires exactly two numeric tokens. Returns `null` if either token fails `Number.parseFloat`.
- `formatCoordinate(coord: { latitude: number; longitude: number }, format?: "decimal" | "dms"): string`
- `"decimal"` (default): each axis as `toFixed(6)`, joined by `", "`.
- `"dms"`: each axis as `D°M'S.ss"H` where `H` is `N`/`S` for latitude and `E`/`W` for longitude.
Neither function validates that values lie within `[-90, 90]` / `[-180, 180]`; treat range checking as a separate concern.
## Implementation Notes
- DMS conversion uses `Math.floor` on the absolute value, then derives minutes and fractional seconds; the sign is consumed only by the hemisphere selector. This avoids the negative-modulo ambiguity that bites naive ports.
- Decimal output is fixed at six decimals (~11 cm precision at the equator) to keep round-trips through `parseCoordinate` lossless within typical surveying tolerances.
- `parseCoordinate` deliberately accepts the union of common separators rather than forcing a single canonical form so that pasted output from Google Maps, GPS exports, and CSV cells all parse without preprocessing.

View File

@@ -0,0 +1,67 @@
# earth-config
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Canonical `PlanetConfig` for Earth and a pre-instantiated `PlanetSurface` bound to it. Application code that needs to reason about Earth — great-circle distance, surface gravity, atmospheric scale height, or the Moon as a satellite — should import these constants rather than re-deriving them at every call site so that every package agrees on a single set of physical values.
The numbers chosen are conservative round figures suitable for game and visualisation use (mean radius `6_371_000 m`, polar radius `6_356_000 m`, surface gravity `9.81 m/s²`, sidereal-rounded rotation period of one solar day, axial tilt `23.44°`, surface pressure `101 325 Pa`, scale height `8 500 m`). They are not intended to substitute for ephemeris-grade geodetic constants (WGS-84) when sub-metre accuracy is required.
## Features
- `EARTH` — frozen-shape `PlanetConfig` literal: mass, mean + polar radii, surface gravity, rotation period, axial tilt, atmosphere model, and the Moon as a satellite.
- `earthSurface` — a `PlanetSurface` instance constructed once from `EARTH`, ready for distance/bearing math without re-allocation.
- Atmosphere block exposes the four scattering coefficients (`rayleighCoefficient`, `mieCoefficient`, `extinctionPerAirMass`, `scaleHeight`) consumed by `@suaveplan/geo-planet`.
- Single satellite entry: Moon, with mass, radius, orbital radius, eccentricity, inclination, period, and albedo.
- Pure data + a single object instance — zero side effects at module load.
## Usage: Basic Example
```ts
import { EARTH, earthSurface } from "@suaveplan/geo-earth";
console.log(EARTH.name); // "Earth"
console.log(earthSurface.planet.radius); // 6371000
```
## Usage: Advanced Example
```ts
import { EARTH, earthSurface } from "@suaveplan/geo-earth";
import type { PlanetConfig } from "@suaveplan/geo-planet";
import { PlanetSurface } from "@suaveplan/geo-planet";
// Derive a denser-atmosphere variant for a what-if simulation
// without mutating the shared EARTH constant.
const denserEarth: PlanetConfig = {
...EARTH,
atmosphere: {
...EARTH.atmosphere!,
surfacePressure: 200000,
scaleHeight: 9500,
},
};
const denserSurface = new PlanetSurface(denserEarth);
console.log(denserSurface.planet.atmosphere?.surfacePressure); // 200000
// The default earthSurface is unchanged.
console.log(earthSurface.planet.atmosphere?.surfacePressure); // 101325
```
## API Reference
- `EARTH: PlanetConfig` — re-exported from `@suaveplan/geo-planet`'s `PlanetConfig` shape. Notable fields:
- `name: "Earth"`, `mass: 5.972e24` kg, `radius: 6_371_000` m, `polarRadius: 6_356_000` m.
- `surfaceGravity: 9.81` m/s², `rotationPeriod: 86_400` s, `axialTilt: 23.44°`.
- `atmosphere`: `{ surfacePressure: 101325, scaleHeight: 8500, rayleighCoefficient: 1, mieCoefficient: 1, extinctionPerAirMass: 1 }`.
- `satellites[0]`: Moon (`mass: 7.342e22`, `radius: 1_737_000`, `orbitalRadius: 384_400_000`, `eccentricity: 0.0549`, `orbitalInclination: 5.14°`, `orbitalPeriod: 2_360_520` s, `albedo: 0.12`).
- `earthSurface: PlanetSurface` — `new PlanetSurface(EARTH)`. See `@suaveplan/geo-planet` for the surface API.
## Implementation Notes
- `EARTH` is exported as a value, not frozen at runtime, but is treated as immutable by convention — clone before mutating.
- The scattering coefficients are placeholder unit values; replace via the spread pattern shown above when integrating a calibrated atmospheric model.
- `earthSurface` is constructed at module import. Because `PlanetSurface` is a thin wrapper, this is cheap and safe to do eagerly.

View File

@@ -0,0 +1,78 @@
# geocoding
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `GeocodingProvider` contract every geocoding backend in the repo must implement, plus a deterministic in-memory provider keyed by an input `Map`. The contract is intentionally narrow — `geocode(query)` for one-shot lookups and `batchGeocode(queries)` for batched workflows — so that swapping a fixture-backed implementation for a Nominatim, Mapbox, or Google adapter is a one-line dependency change in the calling site.
The in-memory provider is the canonical fixture for tests and offline development. It returns the array stored against each query verbatim (or an empty array for misses), giving callers full control over result ordering, multiplicity, and edge cases such as duplicate names.
## Features
- `GeocodingResult` — `{ name, coord }` where `coord` is a `SphericalCoord` from `@suaveplan/geo`.
- `GeocodingProvider` — minimal interface: `geocode(query)` and `batchGeocode(queries)`.
- `createInMemoryProvider(data)` — wraps a `Map<string, GeocodingResult[]>` into the provider interface.
- Misses return `[]`, never `undefined`, simplifying downstream `.length`/iteration code.
- Pure, synchronous, allocation-free per call beyond the result arrays the caller supplied.
## Usage: Basic Example
```ts
import { createInMemoryProvider } from "@suaveplan/geo-earth";
const provider = createInMemoryProvider(
new Map([
[
"London",
[{ name: "London", coord: { latitude: 51.5, longitude: -0.1 } }],
],
])
);
const results = provider.geocode("London");
// [{ name: "London", coord: { latitude: 51.5, longitude: -0.1 } }]
```
## Usage: Advanced Example
```ts
import {
createInMemoryProvider,
type GeocodingProvider,
type GeocodingResult,
} from "@suaveplan/geo-earth";
const fixtures = new Map<string, GeocodingResult[]>([
[
"Paris",
[
{ name: "Paris, FR", coord: { latitude: 48.8566, longitude: 2.3522 } },
{ name: "Paris, TX", coord: { latitude: 33.6609, longitude: -95.5555 } },
],
],
["Tokyo", [{ name: "Tokyo, JP", coord: { latitude: 35.6762, longitude: 139.6503 } }]],
]);
const provider: GeocodingProvider = createInMemoryProvider(fixtures);
const batched = provider.batchGeocode(["Paris", "Tokyo", "Atlantis"]);
// batched[0].length === 2 (Paris ambiguity preserved)
// batched[1].length === 1
// batched[2] === [] (miss → empty array)
```
## API Reference
- `interface GeocodingResult { name: string; coord: SphericalCoord }`
- `interface GeocodingProvider`
- `geocode(query: string): GeocodingResult[]` — synchronous lookup, empty array for miss.
- `batchGeocode(queries: string[]): GeocodingResult[][]` — same length and order as input; per-query miss is an empty array.
- `createInMemoryProvider(data: Map<string, GeocodingResult[]>): GeocodingProvider` — constructs a provider that reads directly from `data`. The `Map` is captured by reference; mutating it after construction is observable on subsequent calls.
## Implementation Notes
- The interface is synchronous because the in-memory provider is, and because a synchronous lower bound forces async adapters (HTTP-backed Nominatim/Mapbox) to declare their own wrapper interface that returns `Promise<GeocodingResult[]>` — they should not silently `await` here.
- `batchGeocode` is a `.map` over `geocode`, which preserves input order and length. Adapters that can issue a true batched request to a remote API should override this for throughput.
- `SphericalCoord` is imported as a type from `@suaveplan/geo` to keep this module type-only against its sibling and avoid runtime coupling.

View File

@@ -0,0 +1,73 @@
# geohash
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encodes a `(latitude, longitude)` pair into a Geohash base32 string and decodes it back to the centre coordinate of the cell. Geohash is a hierarchical spatial index in which a longer string addresses a smaller bounding box, and any prefix of a hash addresses the enclosing cell — making it useful for proximity bucketing, key-prefix range scans on KV stores, and tiled spatial joins without pulling in a full R-tree or quadkey library.
This implementation follows the canonical Niemeyer/Morton algorithm: alternating-axis bit interleaving (longitude on even bits, latitude on odd) with the Geohash base32 alphabet `0123456789bcdefghjkmnpqrstuvwxyz` (intentionally omits `a`, `i`, `l`, `o` to avoid visual confusion).
## Features
- `encodeGeohash(coord, precision = 9)` — produces a base32 string of length `precision`.
- `decodeGeohash(hash)` — returns the centre `{ latitude, longitude }` of the addressed cell.
- Default precision `9` (~4.77 m × 4.77 m at the equator) suits typical store-front / asset-tracking use.
- Pure, deterministic, allocation-light: a single growing string and integer state.
- Universal — no DOM, no Node, no clock, no RNG.
## Usage: Basic Example
```ts
import { decodeGeohash, encodeGeohash } from "@suaveplan/geo-earth";
const hash = encodeGeohash({ latitude: 51.5, longitude: -0.1 }, 9);
const coord = decodeGeohash(hash);
// coord ≈ { latitude: 51.5, longitude: -0.1 }
```
## Usage: Advanced Example
```ts
import { encodeGeohash } from "@suaveplan/geo-earth";
// Bucket a stream of points by 5-character prefix (~4.9 km × 4.9 km cell)
// for cheap proximity grouping in a key/value store.
function bucketByCell<T extends { latitude: number; longitude: number }>(
points: readonly T[],
precision = 5,
): Map<string, T[]> {
const buckets = new Map<string, T[]>();
for (const p of points) {
const cell = encodeGeohash(p, precision);
const list = buckets.get(cell);
if (list) list.push(p);
else buckets.set(cell, [p]);
}
return buckets;
}
const grouped = bucketByCell([
{ latitude: 51.5, longitude: -0.1 },
{ latitude: 51.51, longitude: -0.09 },
{ latitude: 48.86, longitude: 2.35 },
]);
// grouped has two keys: one shared London bucket and one Paris bucket.
```
## API Reference
- `encodeGeohash(coord: { latitude: number; longitude: number }, precision?: number): string`
- `precision` defaults to `9`. Each character contributes 5 bits of resolution (~halving cell size every two characters).
- Returns a string of exactly `precision` characters from the Geohash base32 alphabet.
- `decodeGeohash(hash: string): { latitude: number; longitude: number }`
- Returns the centre of the cell addressed by `hash`. Round-trip fidelity is bounded by the cell size at the chosen precision; the unit tests assert `toBeCloseTo` at one decimal for precision-9 round-trips.
Inputs outside `[-90, 90]` / `[-180, 180]` are not validated; callers that accept arbitrary user input should clamp first.
## Implementation Notes
- Bit interleaving is implemented with the `evenBit` boolean flipping every iteration; on even iterations longitude is bisected, on odd iterations latitude is bisected. This matches the canonical Geohash bit ordering and is what makes string prefixes correspond to enclosing cells.
- Base32 lookup uses `BASE32.indexOf(char)` per character in `decodeGeohash`. For very large decode batches a precomputed `Map<string, number>` would be marginally faster but adds module-load cost; the linear scan is fine for typical workloads at length ≤ 12.
- The decoder returns the cell centre, not the bounding box. Callers needing the box (e.g. for tile rendering) can re-run the bisection loop and capture `minLat/maxLat/minLon/maxLon` themselves.