Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
# `geo`
Geospatial coordinates, planetary data, and solar-system modelling.
<!-- begin:auto-docs -->
## Packages in `geo`
7 packages.
| Package | Description |
|---|---|
| [`@suaveplan/geo`](./geo/README.md) | Abstract spherical and ellipsoidal geometry — pure math with no planet-specific constants. Every operation that needs a body's shape takes an explicit `Ellipso… |
| [`@suaveplan/geo-earth`](./geo-earth/README.md) | Earth configuration and legacy geo utilities. |
| [`@suaveplan/geo-planet`](./geo-planet/README.md) | Configurable planet surface geometry from `PlanetConfig` — ellipsoid, atmosphere, satellites, and orbital elements. |
| [`@suaveplan/solar-system`](./solar-system/README.md) | Solar system provider with astronomy engine. |
| [`@suaveplan/stars`](./stars/README.md) | Star catalog provider. |
| [`@suaveplan/tides`](./tides/README.md) | Tidal prediction providers. |
| [`@suaveplan/universe`](./universe/README.md) | Observer model, coordinate frames, wire protocol, and provider registry. |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Geospatial coordinates, planetary data, and solar-system modelling.

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,53 @@
{
"name": "@suaveplan/geo-earth",
"version": "0.1.0",
"description": "Earth-specific geo utilities: EARTH PlanetConfig and earthSurface, decimal/DMS coordinate parse + format, geohash encode/decode, and an in-memory geocoding provider.",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./earth-config/earth-config": {
"import": "./dist/earth-config/earth-config.js",
"types": "./dist/earth-config/earth-config.d.ts"
},
"./geocoding/geocoding": {
"import": "./dist/geocoding/geocoding.js",
"types": "./dist/geocoding/geocoding.d.ts"
},
"./geohash/geohash": {
"import": "./dist/geohash/geohash.js",
"types": "./dist/geohash/geohash.d.ts"
},
"./coordinates/coordinates": {
"import": "./dist/coordinates/coordinates.js",
"types": "./dist/coordinates/coordinates.d.ts"
}
},
"scripts": {
"build": "vite build",
"lint": "bunx biome check src/",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json"
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/geo": "workspace:^",
"@suaveplan/geo-planet": "workspace:^",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"peerDependencies": {
"typescript": "^5.8"
}
}

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,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { formatCoordinate, parseCoordinate } from "./coordinates.js";
describe("parseCoordinate", () => {
it("parses decimal string", { timeout: 5000 }, () => {
const result = parseCoordinate("51.5, -0.1");
expect(result).toEqual({ latitude: 51.5, longitude: -0.1 });
});
it("parses space-separated", { timeout: 5000 }, () => {
const result = parseCoordinate("51.5 -0.1");
expect(result).toEqual({ latitude: 51.5, longitude: -0.1 });
});
it("returns null for invalid", { timeout: 5000 }, () => {
expect(parseCoordinate("hello")).toBeNull();
});
it("returns null for non-numeric parts", { timeout: 5000 }, () => {
expect(parseCoordinate("a, b")).toBeNull();
});
});
describe("formatCoordinate", () => {
it("formats decimal", { timeout: 5000 }, () => {
const s = formatCoordinate({ latitude: 51.5, longitude: -0.1 });
expect(s).toContain("51.500000");
expect(s).toContain("-0.100000");
});
it("formats dms with positive lat and positive lon", {
timeout: 5000,
}, () => {
const s = formatCoordinate({ latitude: 51.5, longitude: 10.2 }, "dms");
expect(s).toContain("N");
expect(s).toContain("E");
});
it("formats dms with negative lat and negative lon", {
timeout: 5000,
}, () => {
const s = formatCoordinate(
{ latitude: -51.5, longitude: -10.2 },
"dms"
);
expect(s).toContain("S");
expect(s).toContain("W");
});
});

View File

@@ -0,0 +1,36 @@
export function parseCoordinate(input: string): {
latitude: number;
longitude: number;
} | null {
const trimmed = input.trim();
const parts = trimmed.split(/[,\s]+/).filter(Boolean);
if (parts.length === 2) {
const lat = Number.parseFloat(parts[0]!);
const lon = Number.parseFloat(parts[1]!);
if (!Number.isNaN(lat) && !Number.isNaN(lon)) {
return { latitude: lat, longitude: lon };
}
}
return null;
}
export function formatCoordinate(
coord: { latitude: number; longitude: number },
format: "decimal" | "dms" = "decimal"
): string {
if (format === "decimal") {
return `${coord.latitude.toFixed(6)}, ${coord.longitude.toFixed(6)}`;
}
return `${toDms(coord.latitude, "lat")}, ${toDms(coord.longitude, "lon")}`;
}
function toDms(value: number, kind: "lat" | "lon"): string {
const abs = Math.abs(value);
const deg = Math.floor(abs);
const minFloat = (abs - deg) * 60;
const min = Math.floor(minFloat);
const sec = (minFloat - min) * 60;
const dir =
kind === "lat" ? (value >= 0 ? "N" : "S") : value >= 0 ? "E" : "W";
return `${deg}°${min}'${sec.toFixed(2)}"${dir}`;
}

View File

@@ -0,0 +1,4 @@
export {
formatCoordinate,
parseCoordinate,
} from "./coordinates.js";

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,27 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EARTH, earthSurface } from "./earth-config.js";
describe("EARTH", () => {
it("has correct name", { timeout: 5000 }, () => {
expect(EARTH.name).toBe("Earth");
});
it("has positive mass", { timeout: 5000 }, () => {
expect(EARTH.mass).toBeGreaterThan(0);
});
it("has atmosphere", { timeout: 5000 }, () => {
expect(EARTH.atmosphere).not.toBeNull();
});
it("has Moon satellite", { timeout: 5000 }, () => {
expect(EARTH.satellites.length).toBe(1);
expect(EARTH.satellites[0]!.name).toBe("Moon");
});
});
describe("earthSurface", () => {
it("is a PlanetSurface", { timeout: 5000 }, () => {
expect(earthSurface.planet.name).toBe("Earth");
});
});

View File

@@ -0,0 +1,33 @@
import type { PlanetConfig } from "@suaveplan/geo-planet";
import { PlanetSurface } from "@suaveplan/geo-planet";
export const EARTH: PlanetConfig = {
name: "Earth",
mass: 5.972e24,
radius: 6_371_000,
polarRadius: 6_356_000,
surfaceGravity: 9.81,
rotationPeriod: 86_400,
axialTilt: 23.44,
atmosphere: {
surfacePressure: 101325,
scaleHeight: 8500,
rayleighCoefficient: 1,
mieCoefficient: 1,
extinctionPerAirMass: 1,
},
satellites: [
{
name: "Moon",
mass: 7.342e22,
radius: 1_737_000,
orbitalRadius: 384_400_000,
eccentricity: 0.0549,
orbitalInclination: 5.14,
orbitalPeriod: 2_360_520,
albedo: 0.12,
},
],
};
export const earthSurface = new PlanetSurface(EARTH);

View File

@@ -0,0 +1,4 @@
export {
EARTH,
earthSurface,
} from "./earth-config.js";

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,31 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createInMemoryProvider } from "./geocoding.js";
describe("createInMemoryProvider", () => {
const data = new Map([
[
"London",
[{ name: "London", coord: { latitude: 51.5, longitude: -0.1 } }],
],
]);
it("geocodes known query", { timeout: 5000 }, () => {
const provider = createInMemoryProvider(data);
const results = provider.geocode("London");
expect(results.length).toBe(1);
expect(results[0]!.name).toBe("London");
});
it("returns empty for unknown query", { timeout: 5000 }, () => {
const provider = createInMemoryProvider(data);
expect(provider.geocode("Mars")).toEqual([]);
});
it("batch geocodes", { timeout: 5000 }, () => {
const provider = createInMemoryProvider(data);
const results = provider.batchGeocode(["London", "Mars"]);
expect(results.length).toBe(2);
expect(results[0]!.length).toBe(1);
expect(results[1]!).toEqual([]);
});
});

View File

@@ -0,0 +1,24 @@
import type { SphericalCoord } from "@suaveplan/geo";
export interface GeocodingResult {
name: string;
coord: SphericalCoord;
}
export interface GeocodingProvider {
geocode(query: string): GeocodingResult[];
batchGeocode(queries: string[]): GeocodingResult[][];
}
export function createInMemoryProvider(
data: Map<string, GeocodingResult[]>
): GeocodingProvider {
return {
geocode(query: string): GeocodingResult[] {
return data.get(query) ?? [];
},
batchGeocode(queries: string[]): GeocodingResult[][] {
return queries.map((q) => data.get(q) ?? []);
},
};
}

View File

@@ -0,0 +1,5 @@
export type {
GeocodingProvider,
GeocodingResult,
} from "./geocoding.js";
export { createInMemoryProvider } from "./geocoding.js";

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.

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { decodeGeohash, encodeGeohash } from "./geohash.js";
describe("encodeGeohash", () => {
it("encodes a coordinate", { timeout: 5000 }, () => {
const hash = encodeGeohash({ latitude: 51.5, longitude: -0.1 }, 9);
expect(hash.length).toBe(9);
});
it("uses default precision 9", { timeout: 5000 }, () => {
const hash = encodeGeohash({ latitude: 0, longitude: 0 });
expect(hash.length).toBe(9);
});
});
describe("decodeGeohash", () => {
it("round-trips", { timeout: 5000 }, () => {
const coord = { latitude: 51.5, longitude: -0.1 };
const hash = encodeGeohash(coord, 9);
const decoded = decodeGeohash(hash);
expect(decoded.latitude).toBeCloseTo(coord.latitude, 1);
expect(decoded.longitude).toBeCloseTo(coord.longitude, 1);
});
});

View File

@@ -0,0 +1,85 @@
const BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
export function encodeGeohash(
coord: { latitude: number; longitude: number },
precision = 9
): string {
let minLat = -90;
let maxLat = 90;
let minLon = -180;
let maxLon = 180;
let hash = "";
let bits = 0;
let ch = 0;
let evenBit = true;
while (hash.length < precision) {
if (evenBit) {
const mid = (minLon + maxLon) / 2;
if (coord.longitude >= mid) {
ch = (ch << 1) | 1;
minLon = mid;
} else {
ch = ch << 1;
maxLon = mid;
}
} else {
const mid = (minLat + maxLat) / 2;
if (coord.latitude >= mid) {
ch = (ch << 1) | 1;
minLat = mid;
} else {
ch = ch << 1;
maxLat = mid;
}
}
evenBit = !evenBit;
bits++;
if (bits === 5) {
hash += BASE32[ch];
bits = 0;
ch = 0;
}
}
return hash;
}
export function decodeGeohash(hash: string): {
latitude: number;
longitude: number;
} {
let minLat = -90;
let maxLat = 90;
let minLon = -180;
let maxLon = 180;
let evenBit = true;
for (const char of hash) {
const idx = BASE32.indexOf(char);
for (let i = 4; i >= 0; i--) {
const bit = (idx >> i) & 1;
if (evenBit) {
const mid = (minLon + maxLon) / 2;
if (bit === 1) {
minLon = mid;
} else {
maxLon = mid;
}
} else {
const mid = (minLat + maxLat) / 2;
if (bit === 1) {
minLat = mid;
} else {
maxLat = mid;
}
}
evenBit = !evenBit;
}
}
return {
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2,
};
}

View File

@@ -0,0 +1,4 @@
export {
decodeGeohash,
encodeGeohash,
} from "./geohash.js";

View File

@@ -0,0 +1,8 @@
export { formatCoordinate, parseCoordinate } from "./coordinates/index.js";
export { EARTH, earthSurface } from "./earth-config/index.js";
export {
createInMemoryProvider,
type GeocodingProvider,
type GeocodingResult,
} from "./geocoding/index.js";
export { decodeGeohash, encodeGeohash } from "./geohash/index.js";

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: {
index: "./src/index.ts",
"earth-config/earth-config": "./src/earth-config/earth-config.ts",
"geocoding/geocoding": "./src/geocoding/geocoding.ts",
"geohash/geohash": "./src/geohash/geohash.ts",
"coordinates/coordinates": "./src/coordinates/coordinates.ts",
},
formats: ["es"],
},
outDir: "dist",
sourcemap: true,
rollupOptions: {
external: [/^@suaveplan\//, "zod", /^@opentelemetry\//],
output: {
preserveModules: true,
preserveModulesRoot: "src",
},
},
},
plugins: [dts({ tsconfigPath: "./tsconfig.lib.json" })],
});

View File

@@ -0,0 +1,10 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
coverageThresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
});

View File

@@ -0,0 +1,53 @@
# @suaveplan/geo-planet - Configurable planet surface geometry from PlanetConfig — ellipsoid, atmosphere, satellites, and orbital elements.
## Summary
Configurable planet surface geometry from `PlanetConfig` — ellipsoid, atmosphere, satellites, and orbital elements.
<!-- begin:auto-docs -->
## Documentation tree
- [atmospheric](./src/atmospheric/atmospheric.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [atmospheric](./src/atmospheric/atmospheric.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/types/schemas.md) — The `schemas` module exposes Zod runtime schemas mirroring every public type declared in `types.ts`. Validation is the…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [atmospheric](./src/atmospheric/atmospheric.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/types/schemas.md) — The `schemas` module exposes Zod runtime schemas mirroring every public type declared in `types.ts`. Validation is the…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [atmospheric](./src/atmospheric/atmospheric.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/types/schemas.md) — The `schemas` module exposes Zod runtime schemas mirroring every public type declared in `types.ts`. Validation is the…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [atmospheric](./src/atmospheric/atmospheric.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/types/schemas.md) — The `schemas` module exposes Zod runtime schemas mirroring every public type declared in `types.ts`. Validation is the…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [atmospheric](./src/atmospheric/atmospheric.md) — <!-- begin:auto-docs --> _(no submodules)_- [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_ - [planet-surface](./src/planet-surface/planet-surface.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/types/schemas.md) — The `schemas` module exposes Zod runtime schemas mirroring every public type declared in `types.ts`. Validation is the…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/geo-planet
```
## Quick start
```ts
import { PlanetSurface, PlanetConfigSchema } from "@suaveplan/geo-planet";
const config = PlanetConfigSchema.parse({
name: "Mars",
mass: 6.39e23,
radius: 3_396_200,
polarRadius: 3_376_200,
surfaceGravity: 3.72,
rotationPeriod: 88_620,
axialTilt: 25.2,
atmosphere: null,
satellites: [],
});
const surface = new PlanetSurface(config);
console.log(surface.distance(a, b, "kilometre"));
```
## Modules
- [`src/planet-surface/planet-surface.md`](src/planet-surface/planet-surface.md)
- [`src/atmospheric/atmospheric.md`](src/atmospheric/atmospheric.md)
- [`src/types/types.md`](src/types/types.md)

View File

@@ -0,0 +1,65 @@
{
"name": "@suaveplan/geo-planet",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Configurable planet surface geometry from PlanetConfig — ellipsoid, atmosphere, satellites, and orbital elements.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./atmospheric/atmospheric": {
"import": { "types": "./dist/atmospheric/atmospheric.d.ts", "default": "./dist/atmospheric/atmospheric.js" },
"require": { "types": "./dist/atmospheric/atmospheric.d.ts", "default": "./dist/atmospheric/atmospheric.js" }
},
"./planet-surface/planet-surface": {
"import": { "types": "./dist/planet-surface/planet-surface.d.ts", "default": "./dist/planet-surface/planet-surface.js" },
"require": { "types": "./dist/planet-surface/planet-surface.d.ts", "default": "./dist/planet-surface/planet-surface.js" }
},
"./types/types": {
"import": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" },
"require": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/geo": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,86 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `atmospheric` module provides the two physics primitives every planetary renderer, sky-colour solver, and observation-quality estimator needs: the local density of an atmospheric column at altitude, and the relative path length light travels through that column for a given elevation angle. Both functions accept a `PlanetConfig` so the same code path serves Earth, Mars, Titan, or any other modelled body.
These primitives are intentionally pure and free of vendor coupling so they can be reused by `@suaveplan/universe`, `@suaveplan/gaming-environment-lighting`, server-side astronomical computations, and offline analysis without dragging a graphics runtime into the call site.
## Features
- `atmosphericDensity(planet, altitudeMetres)` — exponential isothermal scale-height model that returns `0` for airless bodies
- `airMass(altitudeDeg)` — Kasten–Young 1989 air-mass approximation valid from zenith down to the horizon
- Numerical-stable horizon handling: negative altitudes are clamped to `0` so the formula remains finite at the bound
- Pure functions: no I/O, no side effects, no global state — safe for `Worker` threads, SSR, and deterministic tests
## Usage
### Basic Example
```ts
import { atmosphericDensity } from "@suaveplan/geo-planet";
import type { PlanetConfig } from "@suaveplan/geo-planet";
const earth: PlanetConfig = {
name: "Earth",
mass: 5.972e24,
radius: 6_378_137,
polarRadius: 6_356_752,
surfaceGravity: 9.81,
rotationPeriod: 86_164,
axialTilt: 23.44,
atmosphere: {
surfacePressure: 101_325,
scaleHeight: 8_500,
rayleighCoefficient: 5.8e-6,
mieCoefficient: 21e-6,
extinctionPerAirMass: 0.13,
},
satellites: [],
};
const surface = atmosphericDensity(earth, 0); // 101325 Pa
const cruise = atmosphericDensity(earth, 11_000); // ~27 kPa
```
### Advanced Example
```ts
import { airMass, atmosphericDensity } from "@suaveplan/geo-planet";
import type { PlanetConfig } from "@suaveplan/geo-planet";
function transmittance(
planet: PlanetConfig,
altitudeMetres: number,
sunElevationDeg: number,
): number {
if (!planet.atmosphere) return 1;
const density = atmosphericDensity(planet, altitudeMetres);
const am = airMass(sunElevationDeg);
const tau = planet.atmosphere.extinctionPerAirMass * am;
return Math.exp(-tau) * (density / planet.atmosphere.surfacePressure);
}
// Use at every camera step in a sky shader, or to score
// observability for ground-station pass scheduling.
```
## API Reference
### `atmosphericDensity(planet: PlanetConfig, altitudeMetres: number): number`
Returns the local pressure (`Pa`) using `p₀ · exp(-h / H)` where `p₀` is `planet.atmosphere.surfacePressure` and `H` is `planet.atmosphere.scaleHeight`. Returns `0` when `planet.atmosphere === null`. No clamping is applied to altitude; negative altitudes (subterranean, sub-sea-level) yield pressures above `p₀`, which is the physically correct extrapolation for a barometric law.
### `airMass(altitudeDeg: number): number`
Returns the Kasten–Young air mass:
`1 / (sin(alt) + 0.50572 · (alt + 6.07995)^-1.6364)`
with `alt` in degrees. Negative altitudes are clamped to `0` so the result remains finite at the horizon (returning ≈ `38`). At zenith the return value approaches `1`.
## Implementation Notes
The density function uses the isothermal barometric equation rather than a layered ISA model because every consumer in this project treats density as a smooth analytic function for shader sampling. The Kasten–Young formulation was chosen over the simpler `1 / sin(alt)` secant model because it remains stable down to the horizon and only diverges by ≈ `0.4%` from full atmospheric ray-tracing above 5° elevation. Both functions are allocation-free hot-path code; consumers can call them once per pixel without GC pressure.

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { airMass, atmosphericDensity } from "./atmospheric.js";
describe("atmosphericDensity", () => {
const planet = {
name: "Test",
mass: 1,
radius: 1,
polarRadius: 1,
surfaceGravity: 1,
rotationPeriod: 1,
axialTilt: 0,
atmosphere: {
surfacePressure: 101325,
scaleHeight: 8500,
rayleighCoefficient: 1,
mieCoefficient: 1,
extinctionPerAirMass: 1,
},
satellites: [],
};
it("returns surface pressure at altitude 0", { timeout: 5000 }, () => {
expect(atmosphericDensity(planet, 0)).toBe(101325);
});
it("returns ~37% at scale height", { timeout: 5000 }, () => {
expect(atmosphericDensity(planet, 8500)).toBeCloseTo(
101325 / Math.E,
0
);
});
it("returns 0 for no atmosphere", { timeout: 5000 }, () => {
const noAtmo = { ...planet, atmosphere: null };
expect(atmosphericDensity(noAtmo, 0)).toBe(0);
});
});
describe("airMass", () => {
it("returns approximately 1 at zenith", { timeout: 5000 }, () => {
expect(airMass(90)).toBeCloseTo(1, 1);
});
it("returns a large value at horizon", { timeout: 5000 }, () => {
expect(airMass(0)).toBeGreaterThan(1);
});
it("returns 1 for negative altitude", { timeout: 5000 }, () => {
expect(airMass(-10)).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,19 @@
import type { PlanetConfig } from "../types/types.js";
export function atmosphericDensity(
planet: PlanetConfig,
altitudeMetres: number
): number {
if (!planet.atmosphere) return 0;
const p0 = planet.atmosphere.surfacePressure;
const H = planet.atmosphere.scaleHeight;
return p0 * Math.exp(-altitudeMetres / H);
}
export function airMass(altitudeDeg: number): number {
const alt = Math.max(0, altitudeDeg);
return (
1 /
(Math.sin((alt * Math.PI) / 180) + 0.50572 * (alt + 6.07995) ** -1.6364)
);
}

View File

@@ -0,0 +1,4 @@
export {
airMass,
atmosphericDensity,
} from "./atmospheric.js";

View File

@@ -0,0 +1,16 @@
export { airMass, atmosphericDensity } from "./atmospheric/index.js";
export { PlanetSurface } from "./planet-surface/index.js";
export type {
AtmosphereConfig,
OrbitalConfig,
PlanetConfig,
PlanetTextureManifest,
SatelliteConfig,
} from "./types/index.js";
export {
AtmosphereConfigSchema,
OrbitalConfigSchema,
PlanetConfigSchema,
PlanetTextureManifestSchema,
SatelliteConfigSchema,
} from "./types/index.js";

View File

@@ -0,0 +1 @@
export { PlanetSurface } from "./planet-surface.js";

View File

@@ -0,0 +1,111 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `planet-surface` module provides the `PlanetSurface` class, an oblate-ellipsoid wrapper around a `PlanetConfig` that exposes the geodetic operations every multi-planet system needs: great-circle distance, initial bearing, terminal-point projection from a heading and distance, and point-in-polygon tests. It is the single bridge between body-specific configuration (Earth, Mars, Titan, …) and the body-agnostic geometry primitives in `@suaveplan/geo`.
By deriving the ellipsoid once per `PlanetSurface` instance from the `radius` and `polarRadius` fields of `PlanetConfig`, callers never have to thread an ellipsoid through every distance call, and they cannot accidentally mix Earth-flattening into a Mars computation.
## Features
- `PlanetSurface` class — owns the planet config and the cached `Ellipsoid`
- `distance(a, b, unit?)` — geodesic great-circle distance with selectable distance unit
- `bearing(from, to)` — initial forward azimuth in degrees from north, clockwise
- `destination(start, bearingDeg, distanceM)` — terminal-point projection by direct-geodesic solution
- `pointInPolygon(point, polygon)` — spherical-coordinate ray-cast / winding test for arbitrary closed rings
- Body-agnostic — works for any planet whose oblateness is well-modelled by an ellipsoid of revolution
## Usage
### Basic Example
```ts
import { PlanetSurface } from "@suaveplan/geo-planet";
import type { PlanetConfig } from "@suaveplan/geo-planet";
const earth: PlanetConfig = {
name: "Earth",
mass: 5.972e24,
radius: 6_378_137,
polarRadius: 6_356_752,
surfaceGravity: 9.81,
rotationPeriod: 86_164,
axialTilt: 23.44,
atmosphere: null,
satellites: [],
};
const surface = new PlanetSurface(earth);
const km = surface.distance(
{ latitude: 51.5, longitude: -0.13 }, // London
{ latitude: 40.71, longitude: -74.0 }, // New York
"kilometre",
);
```
### Advanced Example
```ts
import { PlanetSurface } from "@suaveplan/geo-planet";
import type { PlanetConfig } from "@suaveplan/geo-planet";
const mars: PlanetConfig = {
name: "Mars",
mass: 6.39e23,
radius: 3_396_200,
polarRadius: 3_376_200,
surfaceGravity: 3.72,
rotationPeriod: 88_620,
axialTilt: 25.2,
atmosphere: null,
satellites: [],
};
const surface = new PlanetSurface(mars);
// Project a 100 km traverse from Jezero crater on a 45° heading.
const start = { latitude: 18.44, longitude: 77.45 };
const end = surface.destination(start, 45, 100_000);
// Heading toward Olympus Mons.
const heading = surface.bearing(start, { latitude: 18.65, longitude: -133.8 });
// Region containment for science-target zones.
const aoi = [
{ latitude: 18.0, longitude: 77.0 },
{ latitude: 19.0, longitude: 77.0 },
{ latitude: 19.0, longitude: 78.0 },
{ latitude: 18.0, longitude: 78.0 },
];
const inside = surface.pointInPolygon(end, aoi);
```
## API Reference
### `class PlanetSurface`
#### `constructor(planet: PlanetConfig)`
Stores the planet on `surface.planet` and constructs an `Ellipsoid` via `defineEllipsoid(planet.radius, planet.polarRadius)` once per instance.
#### `distance(a, b, unit?: DistanceUnit): number`
Returns the great-circle distance between two `SphericalCoord` points along the cached ellipsoid. `unit` defaults to `"metre"`; pass `"kilometre"`, `"mile"`, etc. as supported by `@suaveplan/geo`.
#### `bearing(from, to): number`
Returns the initial forward azimuth in degrees clockwise from north. Note: the spherical bearing in `@suaveplan/geo` does not depend on the ellipsoid.
#### `destination(start, bearingDeg, distanceM): SphericalCoord`
Solves the direct geodesic problem on the cached ellipsoid: given a start point, an initial bearing, and a distance in metres, returns the terminal point.
#### `pointInPolygon(point, polygon: SphericalCoord[]): boolean`
Tests containment of `point` inside the closed polygon ring; uses spherical-coordinate point-in-polygon math from `@suaveplan/geo`.
## Implementation Notes
The ellipsoid is built once at construction and then reused on every method call, which keeps every distance / destination call allocation-free in steady state. Bearing intentionally does not pass the ellipsoid because the underlying `bearing` from `@suaveplan/geo` is a pure spherical primitive — for the precision needed in mission-planning UIs, the difference between spherical and geodetic bearing is below display granularity, and the geometric simplicity is worth more than the marginal accuracy gain. Storing `planet` as a public readonly property lets consumers retain the original config (for atmosphere, satellites, textures, etc.) without round-tripping through a separate registry.

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PlanetSurface } from "./planet-surface.js";
const TEST_PLANET = {
name: "TestPlanet",
mass: 5.972e24,
radius: 6_371_000,
polarRadius: 6_356_000,
surfaceGravity: 9.81,
rotationPeriod: 86_400,
axialTilt: 23.5,
atmosphere: null,
satellites: [],
};
describe("PlanetSurface", () => {
it("computes distance with default unit", { timeout: 5000 }, () => {
const surface = new PlanetSurface(TEST_PLANET);
const a = { latitude: 0, longitude: 0 };
const b = { latitude: 0, longitude: 1 };
const d = surface.distance(a, b);
expect(d).toBeGreaterThan(0);
});
it("computes distance with explicit unit", { timeout: 5000 }, () => {
const surface = new PlanetSurface(TEST_PLANET);
const a = { latitude: 0, longitude: 0 };
const b = { latitude: 0, longitude: 1 };
const d = surface.distance(a, b, "kilometre");
expect(d).toBeGreaterThan(0);
});
it("computes bearing", { timeout: 5000 }, () => {
const surface = new PlanetSurface(TEST_PLANET);
const a = { latitude: 0, longitude: 0 };
const b = { latitude: 10, longitude: 0 };
expect(surface.bearing(a, b)).toBeCloseTo(0, 0);
});
it("computes destination", { timeout: 5000 }, () => {
const surface = new PlanetSurface(TEST_PLANET);
const start = { latitude: 0, longitude: 0 };
const end = surface.destination(start, 90, 100_000);
expect(end.latitude).toBeDefined();
expect(end.longitude).toBeDefined();
});
it("checks point in polygon", { timeout: 5000 }, () => {
const surface = new PlanetSurface(TEST_PLANET);
const square = [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 10 },
{ latitude: 10, longitude: 10 },
{ latitude: 10, longitude: 0 },
];
expect(
surface.pointInPolygon({ latitude: 5, longitude: 5 }, square)
).toBe(true);
});
});

View File

@@ -0,0 +1,43 @@
import {
bearing,
type DistanceUnit,
defineEllipsoid,
destination,
type Ellipsoid,
greatCircleDistance,
pointInPolygon,
type SphericalCoord,
} from "@suaveplan/geo";
import type { PlanetConfig } from "../types/types.js";
export class PlanetSurface {
private readonly ellipsoid: Ellipsoid;
constructor(public readonly planet: PlanetConfig) {
this.ellipsoid = defineEllipsoid(planet.radius, planet.polarRadius);
}
distance(
a: SphericalCoord,
b: SphericalCoord,
unit: DistanceUnit = "metre"
): number {
return greatCircleDistance(a, b, this.ellipsoid, unit);
}
bearing(from: SphericalCoord, to: SphericalCoord): number {
return bearing(from, to);
}
destination(
start: SphericalCoord,
bearingDeg: number,
distanceM: number
): SphericalCoord {
return destination(start, bearingDeg, distanceM, this.ellipsoid);
}
pointInPolygon(point: SphericalCoord, polygon: SphericalCoord[]): boolean {
return pointInPolygon(point, polygon);
}
}

View File

@@ -0,0 +1,14 @@
export {
AtmosphereConfigSchema,
OrbitalConfigSchema,
PlanetConfigSchema,
PlanetTextureManifestSchema,
SatelliteConfigSchema,
} from "./schemas.js";
export type {
AtmosphereConfig,
OrbitalConfig,
PlanetConfig,
PlanetTextureManifest,
SatelliteConfig,
} from "./types.js";

View File

@@ -0,0 +1,84 @@
## Purpose
The `schemas` module exposes Zod runtime schemas mirroring every public type declared in `types.ts`. Validation is the boundary contract for any consumer accepting raw JSON, configuration files, IPC payloads, or untrusted user input that will later be handed to `PlanetSurface`, atmospheric solvers, or astronomical providers.
By keeping schemas co-located with the type module they validate, `geo-planet` guarantees that the runtime parser and the static `interface` shapes never drift. Every numeric invariant required by physical models (positive masses, normalised eccentricities, non-negative pressures) is enforced at parse time so downstream code can assume well-formed inputs without defensive null checks.
## Features
- `AtmosphereConfigSchema` — non-negative pressure / coefficients, positive scale height
- `SatelliteConfigSchema` — positive mass/radius/orbit, eccentricity ∈ `[0, 1]`, albedo ∈ `[0, 1]`
- `OrbitalConfigSchema` — Keplerian elements (semi-major axis AU, inclination, perihelion, ascending node, mean anomaly, epoch JD)
- `PlanetTextureManifestSchema` — optional diffuse/normal/specular/clouds/night texture URLs
- `PlanetConfigSchema` — composite root validating physical, orbital, and rendering metadata
- Inferred types via `z.infer<typeof Schema>` interoperate with the hand-authored `interface`s in `types.ts`
## Usage
### Basic Example
```ts
import { PlanetConfigSchema } from "@suaveplan/geo-planet";
const earth = PlanetConfigSchema.parse({
name: "Earth",
mass: 5.972e24,
radius: 6_378_137,
polarRadius: 6_356_752,
surfaceGravity: 9.81,
rotationPeriod: 86_164,
axialTilt: 23.44,
atmosphere: null,
satellites: [],
});
```
### Advanced Example
```ts
import {
AtmosphereConfigSchema,
OrbitalConfigSchema,
PlanetConfigSchema,
SatelliteConfigSchema,
} from "@suaveplan/geo-planet";
const result = PlanetConfigSchema.safeParse(rawJson);
if (!result.success) {
for (const issue of result.error.issues) {
console.error(issue.path.join("."), issue.message);
}
throw new Error("invalid planet config");
}
// Per-field schemas compose for partial validation pipelines.
const atmo = AtmosphereConfigSchema.parse(rawJson.atmosphere);
const orbit = OrbitalConfigSchema.parse(rawJson.orbital);
const moon = SatelliteConfigSchema.parse(rawJson.satellites[0]);
```
## API Reference
### `AtmosphereConfigSchema`
Validates `surfacePressure ≥ 0`, `scaleHeight > 0`, and non-negative Rayleigh, Mie, and air-mass extinction coefficients. Throws on negative numbers or missing fields.
### `SatelliteConfigSchema`
Validates non-empty `name`, positive `mass` / `radius` / `orbitalRadius` / `orbitalPeriod`, free `orbitalInclination`, and clamped `eccentricity` and `albedo` in `[0, 1]`.
### `OrbitalConfigSchema`
Validates positive `semiMajorAxisAU`, eccentricity ∈ `[0, 1]`, free angular elements, and any finite epoch Julian Date.
### `PlanetTextureManifestSchema`
All texture URL fields are optional strings; missing fields default to `undefined`.
### `PlanetConfigSchema`
Composite schema enforcing positive `mass` / `radius` / `polarRadius` / `surfaceGravity` / `rotationPeriod`, free `axialTilt`, `atmosphere: AtmosphereConfig | null`, an array of satellites, and optional `orbital` and `textures`.
## Implementation Notes
Every schema is built directly with `zod` and exports the parser; `z.infer` keeps inferred shapes structurally identical to the hand-authored interfaces. The `null`-permissive atmosphere field models airless bodies (Mercury, Moon) without forcing consumers into an option-type at the validation layer. Eccentricity is clamped to a closed interval rather than a half-open one because a parabolic trajectory (`e === 1`) is a useful boundary case for astronomical fixtures even though it is not bound. Schemas avoid `.strict()` so additive forward-compatible fields can be carried by raw payloads without forcing a parser bump.

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PlanetConfigSchema } from "./schemas.js";
describe("PlanetConfigSchema", () => {
it("parses a valid planet config", { timeout: 5000 }, () => {
const parsed = PlanetConfigSchema.parse({
name: "Mars",
mass: 6.39e23,
radius: 3_396_200,
polarRadius: 3_376_200,
surfaceGravity: 3.72,
rotationPeriod: 88_620,
axialTilt: 25.2,
atmosphere: null,
satellites: [],
});
expect(parsed.name).toBe("Mars");
});
it("rejects negative mass", { timeout: 5000 }, () => {
expect(() =>
PlanetConfigSchema.parse({
name: "X",
mass: -1,
radius: 1,
polarRadius: 1,
surfaceGravity: 1,
rotationPeriod: 1,
axialTilt: 0,
atmosphere: null,
satellites: [],
})
).toThrow();
});
});

View File

@@ -0,0 +1,52 @@
import z from "zod";
export const AtmosphereConfigSchema = z.object({
surfacePressure: z.number().nonnegative(),
scaleHeight: z.number().positive(),
rayleighCoefficient: z.number().nonnegative(),
mieCoefficient: z.number().nonnegative(),
extinctionPerAirMass: z.number().nonnegative(),
});
export const SatelliteConfigSchema = z.object({
name: z.string().min(1),
mass: z.number().positive(),
radius: z.number().positive(),
orbitalRadius: z.number().positive(),
orbitalPeriod: z.number().positive(),
orbitalInclination: z.number(),
eccentricity: z.number().min(0).max(1),
albedo: z.number().min(0).max(1),
});
export const OrbitalConfigSchema = z.object({
semiMajorAxisAU: z.number().positive(),
eccentricity: z.number().min(0).max(1),
inclinationDeg: z.number(),
argumentOfPerihelionDeg: z.number(),
ascendingNodeDeg: z.number(),
meanAnomalyAtEpochDeg: z.number(),
epochJd: z.number(),
});
export const PlanetTextureManifestSchema = z.object({
diffuse: z.string().optional(),
normal: z.string().optional(),
specular: z.string().optional(),
clouds: z.string().optional(),
night: z.string().optional(),
});
export const PlanetConfigSchema = z.object({
name: z.string().min(1),
mass: z.number().positive(),
radius: z.number().positive(),
polarRadius: z.number().positive(),
surfaceGravity: z.number().positive(),
rotationPeriod: z.number().positive(),
axialTilt: z.number(),
atmosphere: AtmosphereConfigSchema.nullable(),
satellites: z.array(SatelliteConfigSchema),
orbital: OrbitalConfigSchema.optional(),
textures: PlanetTextureManifestSchema.optional(),
});

View File

@@ -0,0 +1,117 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `types` module declares the static TypeScript interfaces describing every value `geo-planet` accepts and emits. These are the canonical shapes consumed by `PlanetSurface`, atmospheric models, and any downstream renderer or astronomical provider that needs a single source of truth for what defines a planetary body.
Keeping the interfaces in a hand-authored file (rather than relying solely on `z.infer`) lets the compiler emit precise, documented declarations into `.d.ts` outputs without dragging the Zod runtime into type-only consumers. The runtime mirror lives in `schemas.ts` and is verified by tests to stay structurally aligned with these interfaces.
## Features
- `AtmosphereConfig` — exponential atmosphere parameters (surface pressure, scale height, Rayleigh / Mie coefficients, extinction)
- `SatelliteConfig` — physical and orbital parameters for a natural satellite
- `OrbitalConfig` — heliocentric Keplerian elements with epoch Julian Date
- `PlanetTextureManifest` — optional URLs for diffuse / normal / specular / cloud / night textures
- `PlanetConfig` — composite top-level shape: physical body + optional atmosphere + satellites + optional orbital and texture metadata
## Usage
### Basic Example
```ts
import type { PlanetConfig } from "@suaveplan/geo-planet";
const mars: PlanetConfig = {
name: "Mars",
mass: 6.39e23,
radius: 3_396_200,
polarRadius: 3_376_200,
surfaceGravity: 3.72,
rotationPeriod: 88_620,
axialTilt: 25.2,
atmosphere: null,
satellites: [],
};
```
### Advanced Example
```ts
import type {
AtmosphereConfig,
OrbitalConfig,
PlanetConfig,
SatelliteConfig,
} from "@suaveplan/geo-planet";
const earthAtmosphere: AtmosphereConfig = {
surfacePressure: 101_325,
scaleHeight: 8_500,
rayleighCoefficient: 5.8e-6,
mieCoefficient: 21e-6,
extinctionPerAirMass: 0.13,
};
const earthOrbit: OrbitalConfig = {
semiMajorAxisAU: 1.0,
eccentricity: 0.0167,
inclinationDeg: 0.0,
argumentOfPerihelionDeg: 102.94,
ascendingNodeDeg: -11.26,
meanAnomalyAtEpochDeg: 357.51,
epochJd: 2_451_545,
};
const luna: SatelliteConfig = {
name: "Luna",
mass: 7.342e22,
radius: 1_737_400,
orbitalRadius: 384_400_000,
orbitalPeriod: 2_360_592,
orbitalInclination: 5.145,
eccentricity: 0.0549,
albedo: 0.12,
};
const earth: PlanetConfig = {
name: "Earth",
mass: 5.972e24,
radius: 6_378_137,
polarRadius: 6_356_752,
surfaceGravity: 9.81,
rotationPeriod: 86_164,
axialTilt: 23.44,
atmosphere: earthAtmosphere,
satellites: [luna],
orbital: earthOrbit,
textures: { diffuse: "/textures/earth-diffuse.ktx2" },
};
```
## API Reference
### `AtmosphereConfig`
`surfacePressure: number`, `scaleHeight: number`, `rayleighCoefficient: number`, `mieCoefficient: number`, `extinctionPerAirMass: number`.
### `SatelliteConfig`
`name: string`, plus the same physical and orbital scalars as a planet, restricted to `eccentricity ∈ [0, 1]` and `albedo ∈ [0, 1]` at the runtime layer.
### `OrbitalConfig`
`semiMajorAxisAU`, `eccentricity`, `inclinationDeg`, `argumentOfPerihelionDeg`, `ascendingNodeDeg`, `meanAnomalyAtEpochDeg`, `epochJd`.
### `PlanetTextureManifest`
Optional `diffuse`, `normal`, `specular`, `clouds`, `night` texture URL strings.
### `PlanetConfig`
`name`, `mass`, `radius`, `polarRadius`, `surfaceGravity`, `rotationPeriod`, `axialTilt`, `atmosphere: AtmosphereConfig | null`, `satellites: SatelliteConfig[]`, optional `orbital`, optional `textures`.
## Implementation Notes
The atmosphere field is `AtmosphereConfig | null` rather than `AtmosphereConfig | undefined` so airless bodies (Mercury, Moon) are explicitly modelled rather than accidentally omitted; renderers and atmospheric solvers branch on `=== null` directly. Both equatorial `radius` and `polarRadius` are required because `PlanetSurface` constructs an oblate ellipsoid for accurate great-circle math; spherical bodies pass equal values. Orbital and texture metadata are optional because tests, fixtures, and offline renderers often only need physical scalars.

View File

@@ -0,0 +1,50 @@
export interface AtmosphereConfig {
surfacePressure: number;
scaleHeight: number;
rayleighCoefficient: number;
mieCoefficient: number;
extinctionPerAirMass: number;
}
export interface SatelliteConfig {
name: string;
mass: number;
radius: number;
orbitalRadius: number;
orbitalPeriod: number;
orbitalInclination: number;
eccentricity: number;
albedo: number;
}
export interface OrbitalConfig {
semiMajorAxisAU: number;
eccentricity: number;
inclinationDeg: number;
argumentOfPerihelionDeg: number;
ascendingNodeDeg: number;
meanAnomalyAtEpochDeg: number;
epochJd: number;
}
export interface PlanetTextureManifest {
diffuse?: string;
normal?: string;
specular?: string;
clouds?: string;
night?: string;
}
export interface PlanetConfig {
name: string;
mass: number;
radius: number;
polarRadius: number;
surfaceGravity: number;
rotationPeriod: number;
axialTilt: number;
atmosphere: AtmosphereConfig | null;
satellites: SatelliteConfig[];
orbital?: OrbitalConfig;
textures?: PlanetTextureManifest;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../node_modules/.vite/packages/geo-planet",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/geo",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,2 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig();

View File

@@ -0,0 +1,69 @@
# @suaveplan/geo - Abstract spherical and ellipsoidal geometry — pure math with no planet-specific constants.
## Summary
Abstract spherical and ellipsoidal geometry — pure math with no planet-specific constants. Every operation that needs a body's shape takes an explicit `Ellipsoid` parameter.
<!-- begin:auto-docs -->
## Documentation tree
- [angular](./src/angular/angular.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [angular](./src/angular/angular.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bearing](./src/bearing/bearing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bearing](./src/bearing/bearing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bounds](./src/bounds/bounds.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bounds](./src/bounds/bounds.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [distance](./src/distance/distance.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [distance](./src/distance/distance.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ellipsoid](./src/ellipsoid/ellipsoid.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ellipsoid](./src/ellipsoid/ellipsoid.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/observability.md) — OpenTelemetry tracer wrapper for `@suaveplan/geo`.
- [observability](./src/observability/observability.md) — OpenTelemetry tracer wrapper for `@suaveplan/geo`.
- [polygon](./src/polygon/polygon.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [polygon](./src/polygon/polygon.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transforms](./src/transforms/transforms.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transforms](./src/transforms/transforms.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/types/schemas.md) — The `schemas` module exports Zod schemas that validate every public geometric type defined in `@suaveplan/geo`. The sch…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [units](./src/units/units.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [units](./src/units/units.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [angular](./src/angular/angular.md) — <!-- begin:auto-docs --> _(no submodules)_- [bearing](./src/bearing/bearing.md) — <!-- begin:auto-docs --> _(no submodules)_- [bearing](./src/bearing/bearing.md) — <!-- begin:auto-docs --> _(no submodules)_- [bounds](./src/bounds/bounds.md) — <!-- begin:auto-docs --> _(no submodules)_- [bounds](./src/bounds/bounds.md) — <!-- begin:auto-docs --> _(no submodules)_- [distance](./src/distance/distance.md) — <!-- begin:auto-docs --> _(no submodules)_- [distance](./src/distance/distance.md) — <!-- begin:auto-docs --> _(no submodules)_- [ellipsoid](./src/ellipsoid/ellipsoid.md) — <!-- begin:auto-docs --> _(no submodules)_- [ellipsoid](./src/ellipsoid/ellipsoid.md) — <!-- begin:auto-docs --> _(no submodules)_- [polygon](./src/polygon/polygon.md) — <!-- begin:auto-docs --> _(no submodules)_- [polygon](./src/polygon/polygon.md) — <!-- begin:auto-docs --> _(no submodules)_- [transforms](./src/transforms/transforms.md) — <!-- begin:auto-docs --> _(no submodules)_- [transforms](./src/transforms/transforms.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/types/schemas.md) — The `schemas` module exports Zod schemas that validate every public geometric type defined in `@suaveplan/geo`. The sch…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [units](./src/units/units.md) — <!-- begin:auto-docs --> _(no submodules)_- [units](./src/units/units.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability.md) — OpenTelemetry tracer wrapper for `@suaveplan/geo`.
- [angular](./src/angular/angular.md) — <!-- begin:auto-docs --> _(no submodules)_- [bearing](./src/bearing/bearing.md) — <!-- begin:auto-docs --> _(no submodules)_- [bearing](./src/bearing/bearing.md) — <!-- begin:auto-docs --> _(no submodules)_- [bounds](./src/bounds/bounds.md) — <!-- begin:auto-docs --> _(no submodules)_- [bounds](./src/bounds/bounds.md) — <!-- begin:auto-docs --> _(no submodules)_- [distance](./src/distance/distance.md) — <!-- begin:auto-docs --> _(no submodules)_- [distance](./src/distance/distance.md) — <!-- begin:auto-docs --> _(no submodules)_- [ellipsoid](./src/ellipsoid/ellipsoid.md) — <!-- begin:auto-docs --> _(no submodules)_- [ellipsoid](./src/ellipsoid/ellipsoid.md) — <!-- begin:auto-docs --> _(no submodules)_- [polygon](./src/polygon/polygon.md) — <!-- begin:auto-docs --> _(no submodules)_- [polygon](./src/polygon/polygon.md) — <!-- begin:auto-docs --> _(no submodules)_- [transforms](./src/transforms/transforms.md) — <!-- begin:auto-docs --> _(no submodules)_- [transforms](./src/transforms/transforms.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/types/schemas.md) — The `schemas` module exports Zod schemas that validate every public geometric type defined in `@suaveplan/geo`. The sch…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [units](./src/units/units.md) — <!-- begin:auto-docs --> _(no submodules)_- [units](./src/units/units.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability.md) — OpenTelemetry tracer wrapper for `@suaveplan/geo`.
- [angular](./src/angular/angular.md) — <!-- begin:auto-docs --> _(no submodules)_- [bearing](./src/bearing/bearing.md) — <!-- begin:auto-docs --> _(no submodules)_- [bearing](./src/bearing/bearing.md) — <!-- begin:auto-docs --> _(no submodules)_- [bounds](./src/bounds/bounds.md) — <!-- begin:auto-docs --> _(no submodules)_- [bounds](./src/bounds/bounds.md) — <!-- begin:auto-docs --> _(no submodules)_- [distance](./src/distance/distance.md) — <!-- begin:auto-docs --> _(no submodules)_- [distance](./src/distance/distance.md) — <!-- begin:auto-docs --> _(no submodules)_- [ellipsoid](./src/ellipsoid/ellipsoid.md) — <!-- begin:auto-docs --> _(no submodules)_- [ellipsoid](./src/ellipsoid/ellipsoid.md) — <!-- begin:auto-docs --> _(no submodules)_- [polygon](./src/polygon/polygon.md) — <!-- begin:auto-docs --> _(no submodules)_- [polygon](./src/polygon/polygon.md) — <!-- begin:auto-docs --> _(no submodules)_- [transforms](./src/transforms/transforms.md) — <!-- begin:auto-docs --> _(no submodules)_- [transforms](./src/transforms/transforms.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/types/schemas.md) — The `schemas` module exports Zod schemas that validate every public geometric type defined in `@suaveplan/geo`. The sch…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [units](./src/units/units.md) — <!-- begin:auto-docs --> _(no submodules)_- [units](./src/units/units.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability.md) — OpenTelemetry tracer wrapper for `@suaveplan/geo`.
- [angular](./src/angular/angular.md) — <!-- begin:auto-docs --> _(no submodules)_- [bearing](./src/bearing/bearing.md) — <!-- begin:auto-docs --> _(no submodules)_ - [bearing](./src/bearing/bearing.md) — <!-- begin:auto-docs --> _(no submodules)_- [bounds](./src/bounds/bounds.md) — <!-- begin:auto-docs --> _(no submodules)_ - [bounds](./src/bounds/bounds.md) — <!-- begin:auto-docs --> _(no submodules)_- [distance](./src/distance/distance.md) — <!-- begin:auto-docs --> _(no submodules)_ - [distance](./src/distance/distance.md) — <!-- begin:auto-docs --> _(no submodules)_- [ellipsoid](./src/ellipsoid/ellipsoid.md) — <!-- begin:auto-docs --> _(no submodules)_ - [ellipsoid](./src/ellipsoid/ellipsoid.md) — <!-- begin:auto-docs --> _(no submodules)_- [polygon](./src/polygon/polygon.md) — <!-- begin:auto-docs --> _(no submodules)_ - [polygon](./src/polygon/polygon.md) — <!-- begin:auto-docs --> _(no submodules)_- [transforms](./src/transforms/transforms.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transforms](./src/transforms/transforms.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/types/schemas.md) — The `schemas` module exports Zod schemas that validate every public geometric type defined in `@suaveplan/geo`. The sch…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [units](./src/units/units.md) — <!-- begin:auto-docs --> _(no submodules)_ - [units](./src/units/units.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability.md) — OpenTelemetry tracer wrapper for `@suaveplan/geo`.
## Install
```bash
bun add @suaveplan/geo
```
## Quick start
```ts
import { defineEllipsoid, greatCircleDistance, bearing, destination } from "@suaveplan/geo";
const wgs84 = defineEllipsoid(6_378_137, 6_356_752.314245);
const london = { latitude: 51.5074, longitude: -0.1278 };
const paris = { latitude: 48.8566, longitude: 2.3522 };
console.log(greatCircleDistance(london, paris, wgs84, "kilometre")); // ~344
console.log(bearing(london, paris)); // ~154°
```
## Modules
- [`src/ellipsoid/ellipsoid.md`](src/ellipsoid/ellipsoid.md) — `Ellipsoid`, `defineEllipsoid`
- [`src/transforms/transforms.md`](src/transforms/transforms.md) — `sphericalToCartesian`, `cartesianToSpherical`, `toRadians`, `toDegrees`
- [`src/distance/distance.md`](src/distance/distance.md) — `greatCircleDistance`, `vincentyDistance`
- [`src/bearing/bearing.md`](src/bearing/bearing.md) — `bearing`, `destination`, `midpoint`
- [`src/angular/angular.md`](src/angular/angular.md) — `angularSeparation`
- [`src/bounds/bounds.md`](src/bounds/bounds.md) — `BoundingBox`, `createBoundingBox`, `containsPoint`, `expandBox`, `mergeBoxes`, `intersects`
- [`src/polygon/polygon.md`](src/polygon/polygon.md) — `pointInPolygon`, `polygonArea`, `polygonPerimeter`, `simplifyPolygon`
- [`src/units/units.md`](src/units/units.md) — `DistanceUnit`, `convertDistance`
- [`src/types/types.md`](src/types/types.md) — Zod schemas for all public types

View File

@@ -0,0 +1,85 @@
{
"name": "@suaveplan/geo",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Abstract spherical and ellipsoidal geometry — pure math with no planet-specific constants.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./angular/angular": {
"import": { "types": "./dist/angular/angular.d.ts", "default": "./dist/angular/angular.js" },
"require": { "types": "./dist/angular/angular.d.ts", "default": "./dist/angular/angular.js" }
},
"./bearing/bearing": {
"import": { "types": "./dist/bearing/bearing.d.ts", "default": "./dist/bearing/bearing.js" },
"require": { "types": "./dist/bearing/bearing.d.ts", "default": "./dist/bearing/bearing.js" }
},
"./bounds/bounds": {
"import": { "types": "./dist/bounds/bounds.d.ts", "default": "./dist/bounds/bounds.js" },
"require": { "types": "./dist/bounds/bounds.d.ts", "default": "./dist/bounds/bounds.js" }
},
"./distance/distance": {
"import": { "types": "./dist/distance/distance.d.ts", "default": "./dist/distance/distance.js" },
"require": { "types": "./dist/distance/distance.d.ts", "default": "./dist/distance/distance.js" }
},
"./ellipsoid/ellipsoid": {
"import": { "types": "./dist/ellipsoid/ellipsoid.d.ts", "default": "./dist/ellipsoid/ellipsoid.js" },
"require": { "types": "./dist/ellipsoid/ellipsoid.d.ts", "default": "./dist/ellipsoid/ellipsoid.js" }
},
"./polygon/polygon": {
"import": { "types": "./dist/polygon/polygon.d.ts", "default": "./dist/polygon/polygon.js" },
"require": { "types": "./dist/polygon/polygon.d.ts", "default": "./dist/polygon/polygon.js" }
},
"./transforms/transforms": {
"import": { "types": "./dist/transforms/transforms.d.ts", "default": "./dist/transforms/transforms.js" },
"require": { "types": "./dist/transforms/transforms.d.ts", "default": "./dist/transforms/transforms.js" }
},
"./units/units": {
"import": { "types": "./dist/units/units.d.ts", "default": "./dist/units/units.js" },
"require": { "types": "./dist/units/units.d.ts", "default": "./dist/units/units.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,79 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `angular` module computes the angular separation between two points
expressed as `SphericalCoord` values, returning the central angle subtended
on a unit sphere. The result is a pure spherical angle in radians,
independent of any planetary radius or ellipsoid, which makes it the right
primitive for astronomy (angular distance between celestial objects),
antenna and dish pointing, instrument field-of-view checks, and any
calculation where you want to multiply by a chosen radius later rather than
bake one in upfront. Building on this primitive keeps the higher-level
distance kernels (`greatCircleDistance`, `vincentyDistance`) free to apply
their own ellipsoid-aware radii.
## Features
- `angularSeparation(a, b)` — central angle in radians using the spherical
law of cosines.
- Inputs are degrees (matching the rest of `@suaveplan/geo`); the kernel
converts to radians internally.
- Result is clamped to `[0, π]` by guarding the `acos` argument against
floating-point drift past `±1`.
## Usage
### Basic Example
```ts
import { angularSeparation } from "@suaveplan/geo";
const angle = angularSeparation(
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 90 }
);
console.log(angle); // ~1.5708 radians (90°)
```
### Advanced Example
Combine `angularSeparation` with `toDegrees` and a chosen radius to compute
arc-length on bodies other than Earth — for example, on the Moon's mean
radius — without instantiating an ellipsoid:
```ts
import { angularSeparation, toDegrees } from "@suaveplan/geo";
const MOON_RADIUS_KM = 1737.4;
const sep = angularSeparation(
{ latitude: 0, longitude: 23.47 }, // Apollo 11 site
{ latitude: -3.01, longitude: -23.42 } // Apollo 12 site
);
const arcKm = sep * MOON_RADIUS_KM;
console.log(`${toDegrees(sep).toFixed(2)}° / ${arcKm.toFixed(1)} km`);
```
## API Reference
### `angularSeparation(a: SphericalCoord, b: SphericalCoord): number`
Returns the spherical angle in radians between `a` and `b`. The argument to
`Math.acos` is clamped to `[-1, 1]` so identical inputs deterministically
return `0` and antipodal inputs return `π` even when intermediate
floating-point arithmetic produces values like `1.0000000000000002`.
## Implementation Notes
The implementation uses the spherical law of cosines:
`acos(sin φ₁ sin φ₂ + cos φ₁ cos φ₂ cos Δλ)`. For separations below ~1 km
on Earth the haversine formula is more numerically stable, but for the
astronomy and pointing use cases this primitive targets — separations of
many degrees — the clamped cosine formulation is sufficient and faster.
Consumers that need sub-metre Earth-surface precision should reach for
`greatCircleDistance` (haversine) or `vincentyDistance` (ellipsoidal)
instead.

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { angularSeparation } from "./angular.js";
describe("angularSeparation", () => {
it("returns zero for identical points", { timeout: 5000 }, () => {
const a = { latitude: 10, longitude: 20 };
expect(angularSeparation(a, a)).toBeCloseTo(0, 6);
});
it("returns PI for antipodal points", { timeout: 5000 }, () => {
const a = { latitude: 10, longitude: 20 };
const b = { latitude: -10, longitude: 200 };
expect(angularSeparation(a, b)).toBeCloseTo(Math.PI, 2);
});
});

View File

@@ -0,0 +1,15 @@
import type { SphericalCoord } from "../transforms/transforms.js";
import { toRadians } from "../transforms/transforms.js";
export function angularSeparation(
a: SphericalCoord,
b: SphericalCoord
): number {
const lat1 = toRadians(a.latitude);
const lat2 = toRadians(b.latitude);
const dLon = toRadians(b.longitude - a.longitude);
const c =
Math.sin(lat1) * Math.sin(lat2) +
Math.cos(lat1) * Math.cos(lat2) * Math.cos(dLon);
return Math.acos(Math.min(1, Math.max(-1, c)));
}

View File

@@ -0,0 +1 @@
export { angularSeparation } from "./angular.js";

View File

@@ -0,0 +1,55 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `bearing` module computes initial bearings, destination points, and midpoints on an ellipsoid. These are the fundamental navigation primitives for routing, waypoint generation, and boundary interpolation.
## Features
- `bearing(from, to)` — initial compass bearing in degrees (0–360)
- `destination(start, bearingDeg, distanceM, ellipsoid)` — computes the point reached after travelling a given distance on a given bearing
- `midpoint(a, b)` — geographic midpoint between two coordinates
## Usage
### Basic Example
```ts
import { bearing } from "@suaveplan/geo";
const brg = bearing(
{ latitude: 51.5, longitude: -0.1 },
{ latitude: 48.9, longitude: 2.3 }
);
```
### Advanced Example
```ts
import { defineEllipsoid, destination, bearing } from "@suaveplan/geo";
const wgs84 = defineEllipsoid(6_378_137, 6_356_752.314245);
const start = { latitude: 51.5, longitude: -0.1 };
const end = destination(start, 90, 100_000, wgs84);
const back = bearing(end, start); // ~270
```
## API Reference
### `bearing(from, to): number`
Returns initial bearing in degrees clockwise from north.
### `destination(start, bearingDeg, distanceM, ellipsoid): SphericalCoord`
Returns the coordinate reached after travelling `distanceM` metres on `bearingDeg`.
### `midpoint(a, b): SphericalCoord`
Returns the geographic midpoint.
## Implementation Notes
Destination uses the spherical law of cosines with the ellipsoid's semi-major axis as radius. For high-precision geodesic destinations, consumers should iterate `bearing` + `vincentyDistance`.

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defineEllipsoid } from "../ellipsoid/ellipsoid.js";
import { bearing, destination, midpoint } from "./bearing.js";
describe("bearing", () => {
it("returns approximately 0 for northward travel", {
timeout: 5000,
}, () => {
const from = { latitude: 0, longitude: 0 };
const to = { latitude: 10, longitude: 0 };
expect(bearing(from, to)).toBeCloseTo(0, 0);
});
it("returns approximately 90 for eastward travel", {
timeout: 5000,
}, () => {
const from = { latitude: 0, longitude: 0 };
const to = { latitude: 0, longitude: 10 };
expect(bearing(from, to)).toBeCloseTo(90, 0);
});
it("returns approximately 180 for southward travel", {
timeout: 5000,
}, () => {
const from = { latitude: 10, longitude: 0 };
const to = { latitude: 0, longitude: 0 };
expect(bearing(from, to)).toBeCloseTo(180, 0);
});
});
describe("destination", () => {
const sphere = defineEllipsoid(6371000, 6371000);
it("round-trips with bearing", { timeout: 5000 }, () => {
const start = { latitude: 51.5074, longitude: -0.1278 };
const brg = 90;
const dist = 100000;
const end = destination(start, brg, dist, sphere);
const backBearing = bearing(end, start);
expect(backBearing).toBeCloseTo((brg + 180) % 360, -1);
});
});
describe("midpoint", () => {
it("lies between two points on the equator", { timeout: 5000 }, () => {
const a = { latitude: 0, longitude: 0 };
const b = { latitude: 0, longitude: 20 };
const m = midpoint(a, b);
expect(m.latitude).toBeCloseTo(0, 3);
expect(m.longitude).toBeCloseTo(10, 3);
});
});

View File

@@ -0,0 +1,60 @@
import type { Ellipsoid } from "../ellipsoid/ellipsoid.js";
import type { SphericalCoord } from "../transforms/transforms.js";
import { toDegrees, toRadians } from "../transforms/transforms.js";
export function bearing(from: SphericalCoord, to: SphericalCoord): number {
const lat1 = toRadians(from.latitude);
const lat2 = toRadians(to.latitude);
const dLon = toRadians(to.longitude - from.longitude);
const y = Math.sin(dLon) * Math.cos(lat2);
const x =
Math.cos(lat1) * Math.sin(lat2) -
Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon);
let brng = toDegrees(Math.atan2(y, x));
if (brng < 0) brng += 360;
return brng;
}
export function destination(
start: SphericalCoord,
bearingDeg: number,
distanceM: number,
ellipsoid: Ellipsoid
): SphericalCoord {
const angularDistance = distanceM / ellipsoid.semiMajorAxis;
const lat1 = toRadians(start.latitude);
const lon1 = toRadians(start.longitude);
const brng = toRadians(bearingDeg);
const lat2 = Math.asin(
Math.sin(lat1) * Math.cos(angularDistance) +
Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(brng)
);
const lon2 =
lon1 +
Math.atan2(
Math.sin(brng) * Math.sin(angularDistance) * Math.cos(lat1),
Math.cos(angularDistance) - Math.sin(lat1) * Math.sin(lat2)
);
return {
latitude: toDegrees(lat2),
longitude: toDegrees(lon2),
};
}
export function midpoint(a: SphericalCoord, b: SphericalCoord): SphericalCoord {
const lat1 = toRadians(a.latitude);
const lat2 = toRadians(b.latitude);
const lon1 = toRadians(a.longitude);
const dLon = toRadians(b.longitude - a.longitude);
const Bx = Math.cos(lat2) * Math.cos(dLon);
const By = Math.cos(lat2) * Math.sin(dLon);
const lat3 = Math.atan2(
Math.sin(lat1) + Math.sin(lat2),
Math.sqrt((Math.cos(lat1) + Bx) * (Math.cos(lat1) + Bx) + By * By)
);
const lon3 = lon1 + Math.atan2(By, Math.cos(lat1) + Bx);
return {
latitude: toDegrees(lat3),
longitude: toDegrees(lon3),
};
}

View File

@@ -0,0 +1,5 @@
export {
bearing,
destination,
midpoint,
} from "./bearing.js";

View File

@@ -0,0 +1,66 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `bounds` module provides axis-aligned bounding boxes for spherical coordinates. Boxes support containment tests, expansion, merging, and intersection — the primitives needed for spatial indexing, map viewport clipping, and collision detection.
## Features
- `createBoundingBox` — construct from an array of coordinates
- `containsPoint` — point-in-box test
- `expandBox` — grow a box to include a new point
- `mergeBoxes` — combine two boxes
- `intersects` — box-box overlap test
## Usage
### Basic Example
```ts
import { createBoundingBox, containsPoint } from "@suaveplan/geo";
const box = createBoundingBox([
{ latitude: 0, longitude: 0 },
{ latitude: 10, longitude: 10 },
]);
console.log(containsPoint(box, { latitude: 5, longitude: 5 })); // true
```
### Advanced Example
```ts
import { createBoundingBox, mergeBoxes, intersects } from "@suaveplan/geo";
const a = createBoundingBox([{ latitude: 0, longitude: 0 }, { latitude: 10, longitude: 10 }]);
const b = createBoundingBox([{ latitude: 5, longitude: 5 }, { latitude: 15, longitude: 15 }]);
console.log(intersects(a, b)); // true
const merged = mergeBoxes(a, b);
```
## API Reference
### `createBoundingBox(coords): BoundingBox | null`
Returns `null` for an empty array.
### `containsPoint(box, point): boolean`
Inclusive on all boundaries.
### `expandBox(box, point): BoundingBox`
Returns a new box; does not mutate the input.
### `mergeBoxes(a, b): BoundingBox`
Returns the smallest box containing both inputs.
### `intersects(a, b): boolean`
Returns `true` if the boxes share any area.
## Implementation Notes
Bounding boxes use simple min/max on latitude and longitude. They do not handle the antimeridian (`±180°` longitude wrap). Consumers crossing the antimeridian should split geometries or use a custom coordinate system.

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
containsPoint,
createBoundingBox,
expandBox,
intersects,
mergeBoxes,
} from "./bounds.js";
describe("createBoundingBox", () => {
it("returns null for empty array", { timeout: 5000 }, () => {
expect(createBoundingBox([])).toBeNull();
});
it("creates a box from a single point", { timeout: 5000 }, () => {
const box = createBoundingBox([{ latitude: 5, longitude: 10 }]);
expect(box).toEqual({
minLatitude: 5,
maxLatitude: 5,
minLongitude: 10,
maxLongitude: 10,
});
});
it("creates a box from multiple points", { timeout: 5000 }, () => {
const box = createBoundingBox([
{ latitude: 0, longitude: 0 },
{ latitude: 10, longitude: 20 },
{ latitude: -5, longitude: 15 },
]);
expect(box!.minLatitude).toBe(-5);
expect(box!.maxLatitude).toBe(10);
expect(box!.minLongitude).toBe(0);
expect(box!.maxLongitude).toBe(20);
});
it("expands minLongitude when a point has smaller longitude", {
timeout: 5000,
}, () => {
const box = createBoundingBox([
{ latitude: 0, longitude: 10 },
{ latitude: 0, longitude: 5 },
{ latitude: 0, longitude: 7 },
]);
expect(box!.minLongitude).toBe(5);
expect(box!.maxLongitude).toBe(10);
});
});
describe("containsPoint", () => {
const box = {
minLatitude: -10,
maxLatitude: 10,
minLongitude: -10,
maxLongitude: 10,
};
it("returns true for a point inside", { timeout: 5000 }, () => {
expect(containsPoint(box, { latitude: 0, longitude: 0 })).toBe(true);
});
it("returns false for a point outside", { timeout: 5000 }, () => {
expect(containsPoint(box, { latitude: 20, longitude: 0 })).toBe(false);
});
});
describe("expandBox", () => {
it("expands box to include a new point", { timeout: 5000 }, () => {
const box = {
minLatitude: 0,
maxLatitude: 10,
minLongitude: 0,
maxLongitude: 10,
};
const expanded = expandBox(box, { latitude: 15, longitude: 5 });
expect(expanded.maxLatitude).toBe(15);
});
});
describe("mergeBoxes", () => {
it("merges two boxes", { timeout: 5000 }, () => {
const a = {
minLatitude: 0,
maxLatitude: 10,
minLongitude: 0,
maxLongitude: 10,
};
const b = {
minLatitude: 5,
maxLatitude: 15,
minLongitude: 5,
maxLongitude: 15,
};
const merged = mergeBoxes(a, b);
expect(merged.minLatitude).toBe(0);
expect(merged.maxLatitude).toBe(15);
});
});
describe("intersects", () => {
it("returns true for overlapping boxes", { timeout: 5000 }, () => {
const a = {
minLatitude: 0,
maxLatitude: 10,
minLongitude: 0,
maxLongitude: 10,
};
const b = {
minLatitude: 5,
maxLatitude: 15,
minLongitude: 5,
maxLongitude: 15,
};
expect(intersects(a, b)).toBe(true);
});
it("returns false for non-overlapping boxes", { timeout: 5000 }, () => {
const a = {
minLatitude: 0,
maxLatitude: 10,
minLongitude: 0,
maxLongitude: 10,
};
const b = {
minLatitude: 20,
maxLatitude: 30,
minLongitude: 20,
maxLongitude: 30,
};
expect(intersects(a, b)).toBe(false);
});
});

View File

@@ -0,0 +1,73 @@
import type { SphericalCoord } from "../transforms/transforms.js";
export interface BoundingBox {
minLatitude: number;
maxLatitude: number;
minLongitude: number;
maxLongitude: number;
}
export function createBoundingBox(
coords: SphericalCoord[]
): BoundingBox | null {
if (coords.length === 0) return null;
let minLat = coords[0]!.latitude;
let maxLat = coords[0]!.latitude;
let minLon = coords[0]!.longitude;
let maxLon = coords[0]!.longitude;
for (let i = 1; i < coords.length; i++) {
const c = coords[i]!;
if (c.latitude < minLat) minLat = c.latitude;
if (c.latitude > maxLat) maxLat = c.latitude;
if (c.longitude < minLon) minLon = c.longitude;
if (c.longitude > maxLon) maxLon = c.longitude;
}
return {
minLatitude: minLat,
maxLatitude: maxLat,
minLongitude: minLon,
maxLongitude: maxLon,
};
}
export function containsPoint(
box: BoundingBox,
point: SphericalCoord
): boolean {
return (
point.latitude >= box.minLatitude &&
point.latitude <= box.maxLatitude &&
point.longitude >= box.minLongitude &&
point.longitude <= box.maxLongitude
);
}
export function expandBox(
box: BoundingBox,
point: SphericalCoord
): BoundingBox {
return {
minLatitude: Math.min(box.minLatitude, point.latitude),
maxLatitude: Math.max(box.maxLatitude, point.latitude),
minLongitude: Math.min(box.minLongitude, point.longitude),
maxLongitude: Math.max(box.maxLongitude, point.longitude),
};
}
export function mergeBoxes(a: BoundingBox, b: BoundingBox): BoundingBox {
return {
minLatitude: Math.min(a.minLatitude, b.minLatitude),
maxLatitude: Math.max(a.maxLatitude, b.maxLatitude),
minLongitude: Math.min(a.minLongitude, b.minLongitude),
maxLongitude: Math.max(a.maxLongitude, b.maxLongitude),
};
}
export function intersects(a: BoundingBox, b: BoundingBox): boolean {
return (
a.minLatitude <= b.maxLatitude &&
a.maxLatitude >= b.minLatitude &&
a.minLongitude <= b.maxLongitude &&
a.maxLongitude >= b.minLongitude
);
}

View File

@@ -0,0 +1,8 @@
export type { BoundingBox } from "./bounds.js";
export {
containsPoint,
createBoundingBox,
expandBox,
intersects,
mergeBoxes,
} from "./bounds.js";

View File

@@ -0,0 +1,100 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `distance` module computes geodesic distances between two
`SphericalCoord` values on a configurable `Ellipsoid`. Two algorithms are
exported so callers can pick the speed/accuracy tradeoff that fits the use
case: a fast haversine great-circle approximation that treats the
ellipsoid's semi-major axis as a sphere radius, and the iterative Vincenty
inverse formula which solves on the actual ellipsoid and is accurate to
millimetres for any pair of points that aren't nearly antipodal. Both
functions return their result in a caller-chosen `DistanceUnit`, delegating
the conversion to `convertDistance` so the caller never has to remember
the metre→mile or metre→nautical-mile factor.
## Features
- `greatCircleDistance(a, b, ellipsoid, unit?)` — O(1) haversine; ideal
when the ellipsoid is approximately spherical or when ~0.5% accuracy is
acceptable.
- `vincentyDistance(a, b, ellipsoid, unit?)` — iterative inverse Vincenty
with up to 100 iterations and a `1e-12` convergence tolerance; converges
to millimetre precision on WGS-84.
- Both functions accept any `DistanceUnit` (`"metre"`, `"kilometre"`,
`"mile"`, `"nauticalMile"`) for the return value, defaulting to `"metre"`.
- Both functions handle identical inputs deterministically: distance `0`,
no NaN.
## Usage
### Basic Example
```ts
import { defineEllipsoid, greatCircleDistance } from "@suaveplan/geo";
const wgs84 = defineEllipsoid(6_378_137, 6_356_752.314245);
const km = greatCircleDistance(
{ latitude: 51.5, longitude: -0.1 }, // London
{ latitude: 48.9, longitude: 2.3 }, // Paris
wgs84,
"kilometre"
);
console.log(km.toFixed(1)); // ~344.0
```
### Advanced Example
Compare the two algorithms to characterise the haversine error against
Vincenty for a particular use case:
```ts
import {
defineEllipsoid,
greatCircleDistance,
vincentyDistance,
} from "@suaveplan/geo";
const wgs84 = defineEllipsoid(6_378_137, 6_356_752.314245);
const a = { latitude: 35.6762, longitude: 139.6503 }; // Tokyo
const b = { latitude: -33.8688, longitude: 151.2093 }; // Sydney
const haversineMetres = greatCircleDistance(a, b, wgs84, "metre");
const vincentyMetres = vincentyDistance(a, b, wgs84, "metre");
const errorPpm =
((haversineMetres - vincentyMetres) / vincentyMetres) * 1_000_000;
console.log(`haversine: ${haversineMetres.toFixed(0)} m`);
console.log(`vincenty: ${vincentyMetres.toFixed(0)} m`);
console.log(`error: ${errorPpm.toFixed(1)} ppm`);
```
## API Reference
### `greatCircleDistance(a, b, ellipsoid, unit?): number`
Haversine great-circle distance using `ellipsoid.semiMajorAxis` as the
sphere radius. `unit` defaults to `"metre"`. Treats both points as
spherical, so the answer is a great-circle arc, not an ellipsoidal
geodesic.
### `vincentyDistance(a, b, ellipsoid, unit?): number`
Vincenty's inverse formula. Iterates up to 100 times with a `1e-12`
convergence tolerance on `λ`. `unit` defaults to `"metre"`. Returns `0`
immediately for coincident points (when `sinSigma === 0`).
## Implementation Notes
Vincenty does not converge for nearly antipodal points; the 100-iteration
ceiling will simply return the last computed value rather than throw. In
production code that may receive arbitrary input, validate the input
geometry first or fall back to `greatCircleDistance` when convergence is
suspect. For all realistic Earth-surface use cases — same-continent or
even trans-oceanic — the algorithm converges in well under a dozen
iterations. The kernels read only `semiMajorAxis`, `semiMinorAxis`, and
`flattening` from the ellipsoid, so any ellipsoid produced by
`defineEllipsoid` is compatible.

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defineEllipsoid } from "../ellipsoid/ellipsoid.js";
import { greatCircleDistance, vincentyDistance } from "./distance.js";
describe("greatCircleDistance", () => {
const wgs84 = defineEllipsoid(6378137, 6356752.314245);
it("returns approximately 344 km for London to Paris", {
timeout: 5000,
}, () => {
const london = { latitude: 51.5074, longitude: -0.1278 };
const paris = { latitude: 48.8566, longitude: 2.3522 };
const d = greatCircleDistance(london, paris, wgs84, "metre");
expect(d).toBeGreaterThan(340000);
expect(d).toBeLessThan(350000);
});
it("uses default unit metre", { timeout: 5000 }, () => {
const london = { latitude: 51.5074, longitude: -0.1278 };
const paris = { latitude: 48.8566, longitude: 2.3522 };
const d = greatCircleDistance(london, paris, wgs84);
expect(d).toBeGreaterThan(340000);
expect(d).toBeLessThan(350000);
});
it("returns zero for identical points", { timeout: 5000 }, () => {
const p = { latitude: 10, longitude: 20 };
expect(greatCircleDistance(p, p, wgs84, "metre")).toBe(0);
});
});
describe("vincentyDistance", () => {
const wgs84 = defineEllipsoid(6378137, 6356752.314245);
it("returns approximately 344 km for London to Paris", {
timeout: 5000,
}, () => {
const london = { latitude: 51.5074, longitude: -0.1278 };
const paris = { latitude: 48.8566, longitude: 2.3522 };
const d = vincentyDistance(london, paris, wgs84, "metre");
expect(d).toBeGreaterThan(340000);
expect(d).toBeLessThan(350000);
});
it("returns zero for identical points", { timeout: 5000 }, () => {
const p = { latitude: 10, longitude: 20 };
expect(vincentyDistance(p, p, wgs84, "metre")).toBe(0);
});
it("uses default unit metre", { timeout: 5000 }, () => {
const london = { latitude: 51.5074, longitude: -0.1278 };
const paris = { latitude: 48.8566, longitude: 2.3522 };
const d = vincentyDistance(london, paris, wgs84);
expect(d).toBeGreaterThan(340000);
expect(d).toBeLessThan(350000);
});
it("agrees with great-circle for nearby points", { timeout: 5000 }, () => {
const a = { latitude: 51.5, longitude: -0.1 };
const b = { latitude: 51.51, longitude: -0.11 };
const gc = greatCircleDistance(a, b, wgs84, "metre");
const vin = vincentyDistance(a, b, wgs84, "metre");
expect(Math.abs(vin - gc) / gc).toBeLessThan(0.001);
});
it("computes distance for orthogonal points", { timeout: 5000 }, () => {
const a = { latitude: 0, longitude: 0 };
const b = { latitude: 0, longitude: 90 };
const d = vincentyDistance(a, b, wgs84, "metre");
expect(d).toBeGreaterThan(10000000);
expect(d).toBeLessThan(11000000);
});
it("triggers cos2Alpha branch on a sphere", { timeout: 5000 }, () => {
const sphere = defineEllipsoid(6371000, 6371000);
const a = { latitude: 0, longitude: 0 };
const b = { latitude: 0, longitude: 90 };
const d = vincentyDistance(a, b, sphere, "metre");
expect(d).toBeCloseTo((Math.PI / 2) * 6371000, -2);
});
});

View File

@@ -0,0 +1,112 @@
import type { Ellipsoid } from "../ellipsoid/ellipsoid.js";
import type { SphericalCoord } from "../transforms/transforms.js";
import { toRadians } from "../transforms/transforms.js";
import type { DistanceUnit } from "../units/units.js";
import { convertDistance } from "../units/units.js";
export function greatCircleDistance(
a: SphericalCoord,
b: SphericalCoord,
ellipsoid: Ellipsoid,
unit: DistanceUnit = "metre"
): number {
const lat1 = toRadians(a.latitude);
const lat2 = toRadians(b.latitude);
const dLat = lat2 - lat1;
const dLon = toRadians(b.longitude - a.longitude);
const sinDLat2 = Math.sin(dLat / 2);
const sinDLon2 = Math.sin(dLon / 2);
const c =
2 *
Math.asin(
Math.sqrt(
sinDLat2 * sinDLat2 +
Math.cos(lat1) * Math.cos(lat2) * sinDLon2 * sinDLon2
)
);
const distance = ellipsoid.semiMajorAxis * c;
return convertDistance(distance, "metre", unit);
}
export function vincentyDistance(
a: SphericalCoord,
b: SphericalCoord,
ellipsoid: Ellipsoid,
unit: DistanceUnit = "metre"
): number {
const aAxis = ellipsoid.semiMajorAxis;
const bAxis = ellipsoid.semiMinorAxis;
const f = ellipsoid.flattening;
const lat1 = toRadians(a.latitude);
const lat2 = toRadians(b.latitude);
const lon1 = toRadians(a.longitude);
const lon2 = toRadians(b.longitude);
const U1 = Math.atan((1 - f) * Math.tan(lat1));
const U2 = Math.atan((1 - f) * Math.tan(lat2));
const L = lon2 - lon1;
let lambda = L;
let sinSigma = 0;
let cosSigma = 0;
let sigma = 0;
let sinAlpha = 0;
let cos2Alpha = 0;
let cos2SigmaM = 0;
for (let i = 0; i < 100; i++) {
const sinLambda = Math.sin(lambda);
const cosLambda = Math.cos(lambda);
sinSigma = Math.sqrt(
(Math.cos(U2) * sinLambda) ** 2 +
(Math.cos(U1) * Math.sin(U2) -
Math.sin(U1) * Math.cos(U2) * cosLambda) **
2
);
if (sinSigma === 0) {
return 0;
}
cosSigma =
Math.sin(U1) * Math.sin(U2) +
Math.cos(U1) * Math.cos(U2) * cosLambda;
sigma = Math.atan2(sinSigma, cosSigma);
sinAlpha = (Math.cos(U1) * Math.cos(U2) * sinLambda) / sinSigma;
cos2Alpha = 1 - sinAlpha * sinAlpha;
cos2SigmaM =
Math.abs(cos2Alpha) < 1e-15
? 0
: cosSigma - (2 * Math.sin(U1) * Math.sin(U2)) / cos2Alpha;
const C = (f / 16) * cos2Alpha * (4 + f * (4 - 3 * cos2Alpha));
const lambdaPrev = lambda;
lambda =
L +
(1 - C) *
f *
sinAlpha *
(sigma +
C *
sinSigma *
(cos2SigmaM +
C * cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM)));
if (Math.abs(lambda - lambdaPrev) < 1e-12) {
break;
}
}
const u2 = cos2Alpha * ((aAxis * aAxis - bAxis * bAxis) / (bAxis * bAxis));
const A = 1 + (u2 / 16384) * (4096 + u2 * (-768 + u2 * (320 - 175 * u2)));
const B = (u2 / 1024) * (256 + u2 * (-128 + u2 * (74 - 47 * u2)));
const deltaSigma =
B *
sinSigma *
(cos2SigmaM +
(B / 4) *
(cosSigma * (-1 + 2 * cos2SigmaM * cos2SigmaM) -
(B / 6) *
cos2SigmaM *
(-3 + 4 * sinSigma * sinSigma) *
(-3 + 4 * cos2SigmaM * cos2SigmaM)));
const distance = bAxis * A * (sigma - deltaSigma);
return convertDistance(distance, "metre", unit);
}

View File

@@ -0,0 +1,4 @@
export {
greatCircleDistance,
vincentyDistance,
} from "./distance.js";

View File

@@ -0,0 +1,51 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `ellipsoid` module defines the `Ellipsoid` type and the `defineEllipsoid` factory used throughout the `@suaveplan/geo` family. An ellipsoid is the geometric shape that approximates planets and moons, characterized by a semi-major axis (equatorial radius) and a semi-minor axis (polar radius). This module derives flattening and eccentricity automatically so consumers never hand-roll the same arithmetic.
## Features
- Immutable `Ellipsoid` interface with derived properties
- `defineEllipsoid(a, b)` factory that computes flattening and eccentricity
- Pure functions — no side effects, no planet-specific constants
## Usage
### Basic Example
```ts
import { defineEllipsoid } from "@suaveplan/geo/ellipsoid/ellipsoid";
const wgs84 = defineEllipsoid(6_378_137, 6_356_752.314245);
console.log(wgs84.flattening); // ~0.00335
```
### Advanced Example
```ts
import { defineEllipsoid } from "@suaveplan/geo/ellipsoid/ellipsoid";
const mars = defineEllipsoid(3_396_200, 3_376_200);
const sphere = defineEllipsoid(6_371_000, 6_371_000);
console.log(sphere.flattening); // 0
```
## API Reference
### `interface Ellipsoid`
- `semiMajorAxis: number` — equatorial radius in metres
- `semiMinorAxis: number` — polar radius in metres
- `flattening: number` — derived `(a − b) / a`
- `eccentricity: number` — derived from flattening
### `defineEllipsoid(semiMajorAxis, semiMinorAxis): Ellipsoid`
Creates an `Ellipsoid` from the given axes and derives flattening and eccentricity.
## Implementation Notes
Eccentricity is computed as `sqrt(2f − f²)` where `f` is flattening. For a perfect sphere (`a === b`), both flattening and eccentricity are exactly zero.

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defineEllipsoid } from "./ellipsoid.js";
describe("defineEllipsoid", () => {
it("creates an Ellipsoid from semi-major and semi-minor axes", {
timeout: 5000,
}, () => {
const e = defineEllipsoid(6378137, 6356752.314245);
expect(e.semiMajorAxis).toBe(6378137);
expect(e.semiMinorAxis).toBe(6356752.314245);
expect(e.flattening).toBeCloseTo(0.0033528106647474805, 10);
expect(e.eccentricity).toBeCloseTo(0.08181919084262149, 10);
});
it("produces zero flattening for a sphere", { timeout: 5000 }, () => {
const e = defineEllipsoid(6371000, 6371000);
expect(e.flattening).toBe(0);
expect(e.eccentricity).toBe(0);
});
});

View File

@@ -0,0 +1,20 @@
export interface Ellipsoid {
readonly semiMajorAxis: number;
readonly semiMinorAxis: number;
readonly flattening: number;
readonly eccentricity: number;
}
export function defineEllipsoid(
semiMajorAxis: number,
semiMinorAxis: number
): Ellipsoid {
const flattening = (semiMajorAxis - semiMinorAxis) / semiMajorAxis;
const eccentricity = Math.sqrt(2 * flattening - flattening * flattening);
return {
semiMajorAxis,
semiMinorAxis,
flattening,
eccentricity,
};
}

View File

@@ -0,0 +1,2 @@
export type { Ellipsoid } from "./ellipsoid.js";
export { defineEllipsoid } from "./ellipsoid.js";

View File

@@ -0,0 +1,51 @@
export { angularSeparation } from "./angular/index.js";
export {
bearing,
destination,
midpoint,
} from "./bearing/index.js";
export {
type BoundingBox,
containsPoint,
createBoundingBox,
expandBox,
intersects,
mergeBoxes,
} from "./bounds/index.js";
export {
greatCircleDistance,
vincentyDistance,
} from "./distance/index.js";
export {
defineEllipsoid,
type Ellipsoid,
} from "./ellipsoid/index.js";
export {
pointInPolygon,
polygonArea,
polygonPerimeter,
simplifyPolygon,
} from "./polygon/index.js";
export {
cartesianToSpherical,
type Mat3x3,
type SphericalCoord,
sphericalToCartesian,
toDegrees,
toRadians,
type Vec3,
} from "./transforms/index.js";
export {
BoundingBoxSchema,
DistanceUnitSchema,
EllipsoidSchema,
Mat3x3Schema,
SphericalCoordSchema,
Vec3Schema,
} from "./types/index.js";
export {
convertDistance,
type DistanceUnit,
} from "./units/index.js";

View File

@@ -0,0 +1 @@
export { withSpan } from "./observability.js";

View File

@@ -0,0 +1,17 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry tracer wrapper for `@suaveplan/geo`.
## Usage
```ts
import { withSpan } from "@suaveplan/geo";
const result = withSpan("compute", () => compute());
```

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { withSpan } from "./observability.js";
describe("withSpan", () => {
it("returns the function result", { timeout: 5000 }, () => {
const wrapped = withSpan("test", () => 42);
expect(wrapped()).toBe(42);
});
it("re-throws errors", { timeout: 5000 }, () => {
const wrapped = withSpan("test", () => {
throw new Error("fail");
});
expect(() => wrapped()).toThrow("fail");
});
});

View File

@@ -0,0 +1,24 @@
import { trace } from "@opentelemetry/api";
const TRACER = trace.getTracer("@suaveplan/geo");
export function withSpan<T extends (...args: unknown[]) => unknown>(
name: string,
fn: T
): T {
return ((...args: unknown[]) => {
return TRACER.startActiveSpan(name, (span) => {
try {
const result = fn(...args);
span.setStatus({ code: 1 });
return result;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: 2 });
throw err;
} finally {
span.end();
}
});
}) as T;
}

View File

@@ -0,0 +1,6 @@
export {
pointInPolygon,
polygonArea,
polygonPerimeter,
simplifyPolygon,
} from "./polygon.js";

View File

@@ -0,0 +1,63 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `polygon` module provides point-in-polygon, area, perimeter, and simplification for spherical polygons. These are essential for geofencing, territory mapping, and route buffer calculations.
## Features
- `pointInPolygon` — ray-casting algorithm for spherical coordinates
- `polygonArea` — spherical excess area on a given ellipsoid
- `polygonPerimeter` — sum of geodesic edge lengths
- `simplifyPolygon` — Douglas-Peucker with great-circle perpendicular distance
## Usage
### Basic Example
```ts
import { pointInPolygon } from "@suaveplan/geo";
const square = [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 10 },
{ latitude: 10, longitude: 10 },
{ latitude: 10, longitude: 0 },
];
console.log(pointInPolygon({ latitude: 5, longitude: 5 }, square)); // true
```
### Advanced Example
```ts
import { defineEllipsoid, polygonArea, simplifyPolygon } from "@suaveplan/geo";
const wgs84 = defineEllipsoid(6_378_137, 6_356_752.314245);
const complex = [/* many points */];
const simple = simplifyPolygon(complex, 100, wgs84);
const area = polygonArea(simple, wgs84);
```
## API Reference
### `pointInPolygon(point, polygon): boolean`
Ray-casting (even-odd rule). Does not handle polygons that cross the antimeridian.
### `polygonArea(polygon, ellipsoid): number`
Returns area in square metres. Returns `0` for fewer than 3 vertices.
### `polygonPerimeter(polygon, ellipsoid): number`
Returns perimeter in metres using `greatCircleDistance` for each edge.
### `simplifyPolygon(polygon, toleranceMetres, ellipsoid): SphericalCoord[]`
Douglas-Peucker simplification. Returns the original polygon if it has 2 or fewer points.
## Implementation Notes
`polygonArea` uses the spherical excess formula with the ellipsoid's semi-major axis as radius. `simplifyPolygon` computes perpendicular distance using great-circle distances on the supplied ellipsoid rather than planar Euclidean distance, preserving accuracy for large polygons.

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defineEllipsoid } from "../ellipsoid/ellipsoid.js";
import {
pointInPolygon,
polygonArea,
polygonPerimeter,
simplifyPolygon,
} from "./polygon.js";
describe("pointInPolygon", () => {
const square = [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 10 },
{ latitude: 10, longitude: 10 },
{ latitude: 10, longitude: 0 },
];
it("returns true for a point inside", { timeout: 5000 }, () => {
expect(pointInPolygon({ latitude: 5, longitude: 5 }, square)).toBe(
true
);
});
it("returns false for a point outside", { timeout: 5000 }, () => {
expect(pointInPolygon({ latitude: 15, longitude: 5 }, square)).toBe(
false
);
});
});
describe("polygonArea", () => {
const sphere = defineEllipsoid(6371000, 6371000);
it("returns zero for fewer than 3 points", { timeout: 5000 }, () => {
expect(
polygonArea(
[
{ latitude: 0, longitude: 0 },
{ latitude: 1, longitude: 1 },
],
sphere
)
).toBe(0);
});
it("returns positive area for a square", { timeout: 5000 }, () => {
const square = [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 1 },
{ latitude: 1, longitude: 1 },
{ latitude: 1, longitude: 0 },
];
const area = polygonArea(square, sphere);
expect(area).toBeGreaterThan(0);
});
});
describe("polygonPerimeter", () => {
const sphere = defineEllipsoid(6371000, 6371000);
it("returns zero for fewer than 2 points", { timeout: 5000 }, () => {
expect(polygonPerimeter([{ latitude: 0, longitude: 0 }], sphere)).toBe(
0
);
});
it("returns positive perimeter for a square", { timeout: 5000 }, () => {
const square = [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 1 },
{ latitude: 1, longitude: 1 },
{ latitude: 1, longitude: 0 },
];
const perimeter = polygonPerimeter(square, sphere);
expect(perimeter).toBeGreaterThan(0);
});
});
describe("simplifyPolygon", () => {
const sphere = defineEllipsoid(6371000, 6371000);
it("returns the same polygon for 2 or fewer points", {
timeout: 5000,
}, () => {
const poly = [
{ latitude: 0, longitude: 0 },
{ latitude: 1, longitude: 1 },
];
const simplified = simplifyPolygon(poly, 1000, sphere);
expect(simplified).toHaveLength(2);
});
it("simplifies a straight line to endpoints", { timeout: 5000 }, () => {
const poly = [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 1 },
{ latitude: 0, longitude: 2 },
{ latitude: 0, longitude: 3 },
];
const simplified = simplifyPolygon(poly, 1000, sphere);
expect(simplified.length).toBeLessThanOrEqual(poly.length);
expect(simplified[0]).toEqual(poly[0]);
expect(simplified[simplified.length - 1]).toEqual(
poly[poly.length - 1]
);
});
it("keeps a deviating point when tolerance is small", {
timeout: 5000,
}, () => {
const poly = [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 1 },
{ latitude: 5, longitude: 2 },
{ latitude: 0, longitude: 3 },
];
const simplified = simplifyPolygon(poly, 1, sphere);
expect(simplified.length).toBeGreaterThan(2);
});
it("simplifies with a deviating midpoint", { timeout: 5000 }, () => {
const poly = [
{ latitude: 0, longitude: 0 },
{ latitude: 1, longitude: 1 },
{ latitude: 0, longitude: 2 },
];
const simplified = simplifyPolygon(poly, 1000, sphere);
expect(simplified.length).toBeGreaterThanOrEqual(2);
});
it("handles zero-length line in simplification", { timeout: 5000 }, () => {
const poly = [
{ latitude: 0, longitude: 0 },
{ latitude: 1, longitude: 1 },
{ latitude: 0, longitude: 0 },
];
const simplified = simplifyPolygon(poly, 1, sphere);
expect(simplified.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -0,0 +1,111 @@
import { greatCircleDistance } from "../distance/distance.js";
import type { Ellipsoid } from "../ellipsoid/ellipsoid.js";
import type { SphericalCoord } from "../transforms/transforms.js";
import { toRadians } from "../transforms/transforms.js";
export function pointInPolygon(
point: SphericalCoord,
polygon: SphericalCoord[]
): boolean {
let inside = false;
const x = point.longitude;
const y = point.latitude;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i]!.longitude;
const yi = polygon[i]!.latitude;
const xj = polygon[j]!.longitude;
const yj = polygon[j]!.latitude;
const intersect =
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) {
inside = !inside;
}
}
return inside;
}
export function polygonArea(
polygon: SphericalCoord[],
ellipsoid: Ellipsoid
): number {
if (polygon.length < 3) return 0;
let area = 0;
const R = ellipsoid.semiMajorAxis;
for (let i = 0; i < polygon.length; i++) {
const j = (i + 1) % polygon.length;
area +=
toRadians(polygon[j]!.longitude - polygon[i]!.longitude) *
(2 +
Math.sin(toRadians(polygon[i]!.latitude)) +
Math.sin(toRadians(polygon[j]!.latitude)));
}
return Math.abs((area * R * R) / 2);
}
export function polygonPerimeter(
polygon: SphericalCoord[],
ellipsoid: Ellipsoid
): number {
if (polygon.length < 2) return 0;
let perimeter = 0;
for (let i = 0; i < polygon.length; i++) {
const j = (i + 1) % polygon.length;
perimeter += greatCircleDistance(
polygon[i]!,
polygon[j]!,
ellipsoid,
"metre"
);
}
return perimeter;
}
export function simplifyPolygon(
polygon: SphericalCoord[],
toleranceMetres: number,
ellipsoid: Ellipsoid
): SphericalCoord[] {
if (polygon.length <= 2) return polygon;
const stack: [number, number][] = [[0, polygon.length - 1]];
const keep = new Set<number>([0, polygon.length - 1]);
while (stack.length > 0) {
const [start, end] = stack.pop()!;
let maxDist = 0;
let maxIndex = -1;
for (let i = start + 1; i < end; i++) {
const dist = perpendicularDistance(
polygon[i]!,
polygon[start]!,
polygon[end]!,
ellipsoid
);
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
if (maxDist > toleranceMetres) {
keep.add(maxIndex);
stack.push([start, maxIndex]);
stack.push([maxIndex, end]);
}
}
return polygon.filter((_, i) => keep.has(i));
}
function perpendicularDistance(
point: SphericalCoord,
lineStart: SphericalCoord,
lineEnd: SphericalCoord,
ellipsoid: Ellipsoid
): number {
const dStart = greatCircleDistance(point, lineStart, ellipsoid, "metre");
const dEnd = greatCircleDistance(point, lineEnd, ellipsoid, "metre");
const dLine = greatCircleDistance(lineStart, lineEnd, ellipsoid, "metre");
if (dLine === 0) return dStart;
const s = (dStart + dEnd + dLine) / 2;
const area = Math.sqrt(
Math.max(0, s * (s - dStart) * (s - dEnd) * (s - dLine))
);
return (2 * area) / dLine;
}

View File

@@ -0,0 +1,11 @@
export type {
Mat3x3,
SphericalCoord,
Vec3,
} from "./transforms.js";
export {
cartesianToSpherical,
sphericalToCartesian,
toDegrees,
toRadians,
} from "./transforms.js";

View File

@@ -0,0 +1,59 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `transforms` module converts between spherical coordinates (latitude, longitude, altitude) and 3-D Cartesian vectors on a given ellipsoid. It also provides radian/degree helpers used by every other module in `@suaveplan/geo`.
## Features
- `sphericalToCartesian` — converts lat/lon/alt to ECEF (Earth-Centered Earth-Fixed) Cartesian
- `cartesianToSpherical` — inverse conversion using Bowring's iterative method
- `toRadians` / `toDegrees` — conversion helpers
- `SphericalCoord`, `Vec3`, `Mat3x3` types
## Usage
### Basic Example
```ts
import { defineEllipsoid, sphericalToCartesian } from "@suaveplan/geo";
const wgs84 = defineEllipsoid(6_378_137, 6_356_752.314245);
const vec = sphericalToCartesian({ latitude: 51.5, longitude: -0.1, altitude: 0 }, wgs84);
```
### Advanced Example
```ts
import { defineEllipsoid, sphericalToCartesian, cartesianToSpherical } from "@suaveplan/geo";
const wgs84 = defineEllipsoid(6_378_137, 6_356_752.314245);
const coord = { latitude: 51.5, longitude: -0.1, altitude: 100 };
const vec = sphericalToCartesian(coord, wgs84);
const back = cartesianToSpherical(vec, wgs84);
console.log(back.latitude); // ~51.5
```
## API Reference
### `sphericalToCartesian(coord, ellipsoid): Vec3`
Converts a `SphericalCoord` to a Cartesian `Vec3` on the given `Ellipsoid`.
### `cartesianToSpherical(vec, ellipsoid): SphericalCoord`
Converts a `Vec3` back to a `SphericalCoord` on the given `Ellipsoid`.
### `toRadians(degrees): number`
Converts degrees to radians.
### `toDegrees(radians): number`
Converts radians to degrees.
## Implementation Notes
Cartesian-to-spherical uses Bowring's iterative formula which converges rapidly for all latitudes including the poles. The prime vertical radius of curvature `N` is computed from the eccentricity squared.

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defineEllipsoid } from "../ellipsoid/ellipsoid.js";
import {
cartesianToSpherical,
sphericalToCartesian,
toDegrees,
toRadians,
} from "./transforms.js";
describe("toRadians", () => {
it("converts 180 degrees to PI radians", { timeout: 5000 }, () => {
expect(toRadians(180)).toBeCloseTo(Math.PI, 15);
});
it("converts 90 degrees to PI/2 radians", { timeout: 5000 }, () => {
expect(toRadians(90)).toBeCloseTo(Math.PI / 2, 15);
});
});
describe("toDegrees", () => {
it("converts PI radians to 180 degrees", { timeout: 5000 }, () => {
expect(toDegrees(Math.PI)).toBeCloseTo(180, 15);
});
it("converts PI/2 radians to 90 degrees", { timeout: 5000 }, () => {
expect(toDegrees(Math.PI / 2)).toBeCloseTo(90, 15);
});
});
describe("sphericalToCartesian / cartesianToSpherical", () => {
const wgs84 = defineEllipsoid(6378137, 6356752.314245);
it("round-trips London coordinates", { timeout: 5000 }, () => {
const coord = { latitude: 51.5074, longitude: -0.1278, altitude: 0 };
const vec = sphericalToCartesian(coord, wgs84);
const back = cartesianToSpherical(vec, wgs84);
expect(back.latitude).toBeCloseTo(coord.latitude, 3);
expect(back.longitude).toBeCloseTo(coord.longitude, 3);
});
it("round-trips equator at prime meridian", { timeout: 5000 }, () => {
const coord = { latitude: 0, longitude: 0, altitude: 0 };
const vec = sphericalToCartesian(coord, wgs84);
const back = cartesianToSpherical(vec, wgs84);
expect(back.latitude).toBeCloseTo(0, 3);
expect(back.longitude).toBeCloseTo(0, 3);
});
it("round-trips north pole", { timeout: 5000 }, () => {
const coord = { latitude: 90, longitude: 0, altitude: 100 };
const vec = sphericalToCartesian(coord, wgs84);
const back = cartesianToSpherical(vec, wgs84);
expect(back.latitude).toBeCloseTo(90, 3);
expect(back.altitude).toBeCloseTo(100, 1);
});
it("handles missing altitude", { timeout: 5000 }, () => {
const coord = { latitude: 10, longitude: 20 };
const vec = sphericalToCartesian(coord, wgs84);
expect(vec.z).toBeDefined();
});
});

View File

@@ -0,0 +1,78 @@
import type { Ellipsoid } from "../ellipsoid/ellipsoid.js";
export interface SphericalCoord {
latitude: number;
longitude: number;
altitude?: number;
}
export interface Vec3 {
x: number;
y: number;
z: number;
}
export interface Mat3x3 {
m00: number;
m01: number;
m02: number;
m10: number;
m11: number;
m12: number;
m20: number;
m21: number;
m22: number;
}
export function toRadians(degrees: number): number {
return (degrees * Math.PI) / 180;
}
export function toDegrees(radians: number): number {
return (radians * 180) / Math.PI;
}
export function sphericalToCartesian(
coord: SphericalCoord,
ellipsoid: Ellipsoid
): Vec3 {
const latRad = toRadians(coord.latitude);
const lonRad = toRadians(coord.longitude);
const h = coord.altitude ?? 0;
const a = ellipsoid.semiMajorAxis;
const e2 = ellipsoid.eccentricity * ellipsoid.eccentricity;
const sinLat = Math.sin(latRad);
const cosLat = Math.cos(latRad);
const sinLon = Math.sin(lonRad);
const cosLon = Math.cos(lonRad);
const N = a / Math.sqrt(1 - e2 * sinLat * sinLat);
const x = (N + h) * cosLat * cosLon;
const y = (N + h) * cosLat * sinLon;
const z = (N * (1 - e2) + h) * sinLat;
return { x, y, z };
}
export function cartesianToSpherical(
vec: Vec3,
ellipsoid: Ellipsoid
): SphericalCoord {
const a = ellipsoid.semiMajorAxis;
const b = ellipsoid.semiMinorAxis;
const e2 = ellipsoid.eccentricity * ellipsoid.eccentricity;
const p = Math.sqrt(vec.x * vec.x + vec.y * vec.y);
const theta = Math.atan2(vec.z * a, p * b);
const sinTheta = Math.sin(theta);
const cosTheta = Math.cos(theta);
const latitude = toDegrees(
Math.atan2(
vec.z + ((a * a - b * b) / b) * sinTheta * sinTheta * sinTheta,
p - e2 * a * cosTheta * cosTheta * cosTheta
)
);
const longitude = toDegrees(Math.atan2(vec.y, vec.x));
const latRad = toRadians(latitude);
const sinLat = Math.sin(latRad);
const N = a / Math.sqrt(1 - e2 * sinLat * sinLat);
const altitude = p / Math.cos(latRad) - N;
return { latitude, longitude, altitude };
}

View File

@@ -0,0 +1,16 @@
export {
BoundingBoxSchema,
DistanceUnitSchema,
EllipsoidSchema,
Mat3x3Schema,
SphericalCoordSchema,
Vec3Schema,
} from "./schemas.js";
export type {
BoundingBox,
DistanceUnit,
Ellipsoid,
Mat3x3,
SphericalCoord,
Vec3,
} from "./types.js";

View File

@@ -0,0 +1,102 @@
## Purpose
The `schemas` module exports Zod schemas that validate every public geometric
type defined in `@suaveplan/geo`. The schemas are used at API boundaries —
HTTP handlers, message-bus envelopes, persistence reads — wherever runtime
input must be coerced and rejected before it reaches the geometry kernels.
Pairing the schemas with the typed interfaces in `types.ts` lets consumers
infer the same TypeScript shape via `z.infer` without re-declaring a parallel
type, eliminating drift between compile-time and runtime contracts.
## Features
- `EllipsoidSchema` — validates `semiMajorAxis`/`semiMinorAxis` are positive
numbers and `flattening`/`eccentricity` are finite numbers.
- `SphericalCoordSchema` — clamps `latitude` to `[-90, 90]`, `longitude` to
`[-180, 180]`, and accepts an optional `altitude` number.
- `Vec3Schema` — three finite-number Cartesian components.
- `Mat3x3Schema` — nine finite-number row-major rotation-matrix entries.
- `BoundingBoxSchema` — both latitude bounds in `[-90, 90]` and both
longitude bounds in `[-180, 180]`.
- `DistanceUnitSchema` — enum over `"metre" | "kilometre" | "mile" |
"nauticalMile"`.
## Usage
### Basic Example
```ts
import { SphericalCoordSchema } from "@suaveplan/geo";
const coord = SphericalCoordSchema.parse({
latitude: 51.5,
longitude: -0.1,
});
```
### Advanced Example
```ts
import {
BoundingBoxSchema,
DistanceUnitSchema,
EllipsoidSchema,
} from "@suaveplan/geo";
import type { z } from "zod";
type ParsedEllipsoid = z.infer<typeof EllipsoidSchema>;
function loadConfig(raw: unknown): {
ellipsoid: ParsedEllipsoid;
box: z.infer<typeof BoundingBoxSchema>;
unit: z.infer<typeof DistanceUnitSchema>;
} {
const obj = raw as Record<string, unknown>;
return {
ellipsoid: EllipsoidSchema.parse(obj.ellipsoid),
box: BoundingBoxSchema.parse(obj.box),
unit: DistanceUnitSchema.parse(obj.unit),
};
}
```
## API Reference
### `EllipsoidSchema`
`z.object({ semiMajorAxis: z.number().positive(), semiMinorAxis:
z.number().positive(), flattening: z.number(), eccentricity: z.number() })`.
Throws `ZodError` on negative or non-finite axes.
### `SphericalCoordSchema`
`z.object({ latitude: z.number().min(-90).max(90), longitude:
z.number().min(-180).max(180), altitude: z.number().optional() })`.
### `Vec3Schema`
`z.object({ x, y, z })` — three `z.number()` fields.
### `Mat3x3Schema`
`z.object({ m00..m22 })` — nine `z.number()` fields in row-major order.
### `BoundingBoxSchema`
`z.object({ minLatitude, maxLatitude, minLongitude, maxLongitude })` with
each value clamped to spherical ranges. Note that the schema does NOT verify
`minLatitude <= maxLatitude`; that ordering check is the caller's job.
### `DistanceUnitSchema`
`z.enum(["metre", "kilometre", "mile", "nauticalMile"])`.
## Implementation Notes
The schemas are deliberately permissive on cross-field invariants — for
example, `BoundingBoxSchema` does not verify that `min` ≤ `max`, and
`EllipsoidSchema` does not verify that `semiMinorAxis ≤ semiMajorAxis`.
Cross-field invariants belong with the constructors (`createBoundingBox`,
`defineEllipsoid`), which are tested for those invariants separately. The
schemas exist to reject malformed wire data, not to substitute for the
domain constructors.

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BoundingBoxSchema,
DistanceUnitSchema,
EllipsoidSchema,
SphericalCoordSchema,
} from "./schemas.js";
describe("EllipsoidSchema", () => {
it("parses a valid ellipsoid", { timeout: 5000 }, () => {
const parsed = EllipsoidSchema.parse({
semiMajorAxis: 6378137,
semiMinorAxis: 6356752,
flattening: 0.00335,
eccentricity: 0.0818,
});
expect(parsed.semiMajorAxis).toBe(6378137);
});
it("rejects negative semi-major axis", { timeout: 5000 }, () => {
expect(() =>
EllipsoidSchema.parse({
semiMajorAxis: -1,
semiMinorAxis: 6356752,
flattening: 0,
eccentricity: 0,
})
).toThrow();
});
});
describe("SphericalCoordSchema", () => {
it("parses a valid coordinate", { timeout: 5000 }, () => {
const parsed = SphericalCoordSchema.parse({
latitude: 51.5,
longitude: -0.1,
});
expect(parsed.latitude).toBe(51.5);
});
it("rejects out-of-range latitude", { timeout: 5000 }, () => {
expect(() =>
SphericalCoordSchema.parse({ latitude: 91, longitude: 0 })
).toThrow();
});
});
describe("BoundingBoxSchema", () => {
it("parses a valid box", { timeout: 5000 }, () => {
const parsed = BoundingBoxSchema.parse({
minLatitude: -10,
maxLatitude: 10,
minLongitude: -10,
maxLongitude: 10,
});
expect(parsed.minLatitude).toBe(-10);
});
});
describe("DistanceUnitSchema", () => {
it("parses metre", { timeout: 5000 }, () => {
expect(DistanceUnitSchema.parse("metre")).toBe("metre");
});
it("rejects invalid unit", { timeout: 5000 }, () => {
expect(() => DistanceUnitSchema.parse("yard")).toThrow();
});
});

View File

@@ -0,0 +1,46 @@
import z from "zod";
export const EllipsoidSchema = z.object({
semiMajorAxis: z.number().positive(),
semiMinorAxis: z.number().positive(),
flattening: z.number(),
eccentricity: z.number(),
});
export const SphericalCoordSchema = z.object({
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
altitude: z.number().optional(),
});
export const Vec3Schema = z.object({
x: z.number(),
y: z.number(),
z: z.number(),
});
export const Mat3x3Schema = z.object({
m00: z.number(),
m01: z.number(),
m02: z.number(),
m10: z.number(),
m11: z.number(),
m12: z.number(),
m20: z.number(),
m21: z.number(),
m22: z.number(),
});
export const BoundingBoxSchema = z.object({
minLatitude: z.number().min(-90).max(90),
maxLatitude: z.number().min(-90).max(90),
minLongitude: z.number().min(-180).max(180),
maxLongitude: z.number().min(-180).max(180),
});
export const DistanceUnitSchema = z.enum([
"metre",
"kilometre",
"mile",
"nauticalMile",
]);

View File

@@ -0,0 +1,114 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `types` module declares the shared TypeScript interfaces and enums that
every other module in `@suaveplan/geo` consumes — `Ellipsoid`,
`SphericalCoord`, `Vec3`, `Mat3x3`, `BoundingBox`, and the `DistanceUnit`
string-literal union. Centralising the shapes here keeps the rest of the
package free of duplicate declarations and ensures that any change to a
core geometric structure has exactly one source of truth. The module is
runtime-free: it emits zero JavaScript and is safe to import from any
tier (universal, browser, or server).
## Features
- `Ellipsoid` — readonly `semiMajorAxis`/`semiMinorAxis`/`flattening`/
`eccentricity`; produced by `defineEllipsoid`.
- `SphericalCoord` — `latitude`, `longitude`, optional `altitude` (all
degrees / metres).
- `Vec3` — `x`, `y`, `z` Cartesian components.
- `Mat3x3` — nine row-major rotation-matrix entries (`m00`..`m22`).
- `BoundingBox` — `minLatitude`, `maxLatitude`, `minLongitude`,
`maxLongitude`.
- `DistanceUnit` — `"metre" | "kilometre" | "mile" | "nauticalMile"`.
## Usage
### Basic Example
```ts
import type { Ellipsoid, SphericalCoord } from "@suaveplan/geo";
function describe(point: SphericalCoord, ellipsoid: Ellipsoid): string {
return `(${point.latitude}, ${point.longitude}) on ${ellipsoid.semiMajorAxis}m sphere`;
}
```
### Advanced Example
Compose the types into a domain-specific record without re-declaring any
field shapes:
```ts
import type {
BoundingBox,
DistanceUnit,
SphericalCoord,
} from "@suaveplan/geo";
interface Region {
name: string;
centre: SphericalCoord;
bbox: BoundingBox;
measurementUnit: DistanceUnit;
}
const london: Region = {
name: "Greater London",
centre: { latitude: 51.5074, longitude: -0.1278 },
bbox: {
minLatitude: 51.28,
maxLatitude: 51.69,
minLongitude: -0.51,
maxLongitude: 0.33,
},
measurementUnit: "kilometre",
};
```
## API Reference
### `Ellipsoid`
`{ readonly semiMajorAxis: number; readonly semiMinorAxis: number;
readonly flattening: number; readonly eccentricity: number }`. All values
are in metres except the dimensionless `flattening` and `eccentricity`.
### `SphericalCoord`
`{ latitude: number; longitude: number; altitude?: number }`. `latitude`
and `longitude` are degrees; `altitude` (when present) is metres above
the ellipsoid surface.
### `Vec3`
`{ x: number; y: number; z: number }` — Cartesian components, typically in
ECEF metres.
### `Mat3x3`
Nine numeric fields `m00`..`m22` in row-major order. The rotation
operators in the `transforms` module produce and consume this shape.
### `BoundingBox`
`{ minLatitude: number; maxLatitude: number; minLongitude: number;
maxLongitude: number }`. Caller is responsible for ensuring `min ≤ max`;
`createBoundingBox` enforces this invariant.
### `DistanceUnit`
`"metre" | "kilometre" | "mile" | "nauticalMile"` — accepted everywhere a
unit is required. Conversion factors live in `units.ts`.
## Implementation Notes
The interfaces are intentionally structural — there are no nominal brands
— so values produced by other libraries that match the shape are accepted
without conversion. Runtime validation lives in the sibling `schemas.ts`
module via Zod, which mirrors these shapes one-for-one. When you change a
type here, update the matching schema in the same commit; the package's
test suite includes round-trip checks that catch drift between the two.

View File

@@ -0,0 +1,39 @@
export interface Ellipsoid {
readonly semiMajorAxis: number;
readonly semiMinorAxis: number;
readonly flattening: number;
readonly eccentricity: number;
}
export interface SphericalCoord {
latitude: number;
longitude: number;
altitude?: number;
}
export interface Vec3 {
x: number;
y: number;
z: number;
}
export interface Mat3x3 {
m00: number;
m01: number;
m02: number;
m10: number;
m11: number;
m12: number;
m20: number;
m21: number;
m22: number;
}
export interface BoundingBox {
minLatitude: number;
maxLatitude: number;
minLongitude: number;
maxLongitude: number;
}
export type DistanceUnit = "metre" | "kilometre" | "mile" | "nauticalMile";

View File

@@ -0,0 +1,2 @@
export type { DistanceUnit } from "./units.js";
export { convertDistance } from "./units.js";

View File

@@ -0,0 +1,95 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `units` module converts scalar distance values between metres,
kilometres, statute miles, and nautical miles. It is the single conversion
hub used by every higher-level kernel in `@suaveplan/geo` — every distance
function returns its result by piping the kernel-internal metre value
through `convertDistance` to the caller's chosen unit, so consumers never
have to remember a conversion factor or copy-paste one. Exposing the
function publicly also lets callers normalise mixed-unit external inputs
(e.g. an aviation feed that mixes nautical miles and metres) into a single
working unit before invoking other geometry primitives.
## Features
- `convertDistance(value, from, to)` — bidirectional conversion across all
supported units in O(1).
- `DistanceUnit` string-literal union — `"metre" | "kilometre" | "mile" |
"nauticalMile"`.
- Identity short-circuit: when `from === to` the input is returned as-is
without any floating-point round-trip.
- Conversion factors are exact per international standards (no
hand-rounded constants).
## Usage
### Basic Example
```ts
import { convertDistance } from "@suaveplan/geo";
const km = convertDistance(1000, "metre", "kilometre"); // 1
const miles = convertDistance(10_000, "metre", "mile"); // 6.2137...
```
### Advanced Example
Build a unit-aware helper that accepts any input unit and emits a
canonicalised metre value for downstream geometry kernels:
```ts
import {
convertDistance,
type DistanceUnit,
greatCircleDistance,
defineEllipsoid,
} from "@suaveplan/geo";
interface Reading {
value: number;
unit: DistanceUnit;
}
function toMetres(r: Reading): number {
return convertDistance(r.value, r.unit, "metre");
}
const wgs84 = defineEllipsoid(6_378_137, 6_356_752.314245);
const allowance = toMetres({ value: 5, unit: "nauticalMile" }); // 9260
const distance = greatCircleDistance(
{ latitude: 51.5, longitude: -0.1 },
{ latitude: 48.9, longitude: 2.3 },
wgs84,
"metre"
);
const withinRange = distance <= allowance;
```
## API Reference
### `convertDistance(value: number, from: DistanceUnit, to: DistanceUnit): number`
Returns `value` expressed in `to`. When `from === to` the function returns
`value` unchanged — no multiplication and no division, which keeps the
identity case bit-exact. Otherwise it computes
`value * factor[from] / factor[to]` where the factor table is
`{ metre: 1, kilometre: 1000, mile: 1609.344, nauticalMile: 1852 }`.
### `type DistanceUnit`
`"metre" | "kilometre" | "mile" | "nauticalMile"`. Re-exported from the
package root.
## Implementation Notes
Conversion factors are exact under the modern international definitions:
1 statute mile = 1609.344 m and 1 nautical mile = 1852 m. The function
performs at most one multiply and one divide, so the output is within
one ULP of the mathematical result and round-trip
(`x → from → to → from`) is stable. No rounding is applied internally;
callers that need a fixed display precision should round at presentation
time.

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { convertDistance } from "./units.js";
describe("convertDistance", () => {
it("returns the same value when units match", { timeout: 5000 }, () => {
expect(convertDistance(1000, "metre", "metre")).toBe(1000);
});
it("converts metres to kilometres", { timeout: 5000 }, () => {
expect(convertDistance(1000, "metre", "kilometre")).toBe(1);
});
it("converts kilometres to metres", { timeout: 5000 }, () => {
expect(convertDistance(1, "kilometre", "metre")).toBe(1000);
});
it("converts miles to metres", { timeout: 5000 }, () => {
expect(convertDistance(1, "mile", "metre")).toBeCloseTo(1609.344, 6);
});
it("converts nautical miles to metres", { timeout: 5000 }, () => {
expect(convertDistance(1, "nauticalMile", "metre")).toBe(1852);
});
});

View File

@@ -0,0 +1,18 @@
export type DistanceUnit = "metre" | "kilometre" | "mile" | "nauticalMile";
const CONVERSIONS: Record<DistanceUnit, number> = {
metre: 1,
kilometre: 1000,
mile: 1609.344,
nauticalMile: 1852,
};
export function convertDistance(
value: number,
from: DistanceUnit,
to: DistanceUnit
): number {
if (from === to) return value;
const inMetres = value * CONVERSIONS[from];
return inMetres / CONVERSIONS[to];
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../node_modules/.vite/packages/geo",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,2 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig();

View File

@@ -0,0 +1,19 @@
# @suaveplan/solar-system - Solar-system provider with injectable astronomy engine and season/equinox/solstice/sunrise/sunset/lunar-phase calculations; emits CelestialBody records for the Sun, Moon, and inner/outer planets via a SolarProvider contract.
## Summary
Solar system provider with astronomy engine.
<!-- begin:auto-docs -->
## Documentation tree
- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [seasons](./src/seasons/seasons.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [seasons](./src/seasons/seasons.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [seasons](./src/seasons/seasons.md) — <!-- begin:auto-docs --> _(no submodules)_- [seasons](./src/seasons/seasons.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [seasons](./src/seasons/seasons.md) — <!-- begin:auto-docs --> _(no submodules)_- [seasons](./src/seasons/seasons.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [seasons](./src/seasons/seasons.md) — <!-- begin:auto-docs --> _(no submodules)_- [seasons](./src/seasons/seasons.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/provider/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [seasons](./src/seasons/seasons.md) — <!-- begin:auto-docs --> _(no submodules)_ - [seasons](./src/seasons/seasons.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/solar-system
```

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/solar-system",
"version": "0.1.0",
"description": "Solar-system provider with injectable astronomy engine and season/equinox/solstice/sunrise/sunset/lunar-phase calculations; emits CelestialBody records for the Sun, Moon, and inner/outer planets via a SolarProvider contract.",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./provider/provider": {
"import": "./dist/provider/provider.js",
"types": "./dist/provider/provider.d.ts"
},
"./seasons/seasons": {
"import": "./dist/seasons/seasons.js",
"types": "./dist/seasons/seasons.d.ts"
}
},
"scripts": {
"build": "vite build",
"lint": "bunx biome check src/",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json"
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/geo": "workspace:^",
"@suaveplan/geo-planet": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/universe": "workspace:^",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"peerDependencies": {
"typescript": "^5.8"
}
}

View File

@@ -0,0 +1,14 @@
export {
type AstronomyEngine,
createSolarSystemProvider,
DefaultEngine,
} from "./provider/index.js";
export {
getLunarPhase,
getNextEquinox,
getNextSolstice,
getSeason,
getSunrise,
getSunset,
type Season,
} from "./seasons/index.js";

View File

@@ -0,0 +1,5 @@
export type { AstronomyEngine } from "./provider.js";
export {
createSolarSystemProvider,
DefaultEngine,
} from "./provider.js";

View File

@@ -0,0 +1,61 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Composes a `SolarProvider` (from `@suaveplan/types`) over an injectable `AstronomyEngine` so callers can swap a deterministic stub, a high-precision ephemeris (e.g. VSOP87, JPL DE), or a remote API without touching downstream code. The provider emits `CelestialBody` records for the Sun, Moon, and the classical naked-eye planets, and delegates sunrise/sunset to the `seasons` module so a single calling site covers the full solar-day surface.
## Features
- `AstronomyEngine` interface — single seam (`bodyPosition(name, jd)`) for arbitrary ephemeris back-ends.
- `DefaultEngine` — pure-deterministic placeholder useful for tests, demos, and offline builds.
- `createSolarSystemProvider(engine?)` — factory returning a `SolarProvider` with sensible defaults.
- Returns the seven canonical bodies (Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn) in `getBodies()`.
- Per-body lookup via `getBody(name)` with automatic `"star" | "moon" | "planet"` typing.
- Sunrise / sunset honour an `Observer` argument so future engines can plug in topocentric refinements.
## Usage: Basic Example
```ts
import { createSolarSystemProvider } from "@suaveplan/solar-system";
const provider = createSolarSystemProvider();
const sun = await provider.getBody("Sun");
console.log(sun.type); // "star"
```
## Usage: Advanced Example
```ts
import {
type AstronomyEngine,
createSolarSystemProvider,
} from "@suaveplan/solar-system";
class Vsop87Engine implements AstronomyEngine {
bodyPosition(name: string, jd: number) {
// delegate to a real ephemeris — values are heliocentric AU
return computeVsop87(name, jd);
}
}
const provider = createSolarSystemProvider(new Vsop87Engine());
const bodies = await provider.getBodies();
const observer = {
kind: "surface" as const,
planet: { name: "Earth", radius: 6_371_000, rotationPeriod: 86_164 },
coord: { latitude: 51.5, longitude: -0.1 },
};
const sunrise = await provider.getSunrise(observer, new Date());
```
## API Reference
- `interface AstronomyEngine` — `bodyPosition(name: string, jd: number): { x: number; y: number; z: number }`. Returns heliocentric coordinates (AU) at the given Julian Date.
- `class DefaultEngine implements AstronomyEngine` — deterministic placeholder; `bodyPosition` returns `{ x, y, z }` derived from the first character code of `name`, yielding `{0,0,0}` for empty names.
- `createSolarSystemProvider(engine?: AstronomyEngine): SolarProvider` — `engine` defaults to a fresh `DefaultEngine`. Returned provider exposes `getBodies()`, `getBody(name)`, `getSunrise(observer, date)`, and `getSunset(observer, date)`. None of the methods throw under normal usage; engine errors surface as rejected promises.
## Implementation Notes
The provider is intentionally engine-agnostic: every astronomical computation flows through `AstronomyEngine.bodyPosition`, so swapping engines never requires editing `provider.ts`. `makeBody` currently fixes apparent magnitude, angular diameter, and topocentric altitude/azimuth — these are hooks for future engine extensions rather than computed values, and call sites should treat them as best-effort placeholders until a precision engine is wired in. Sunrise and sunset are delegated to the sibling `seasons` module to keep the time-of-day surface cohesive and testable. The factory accepts an `engine` argument so consumers can substitute deterministic engines in tests without `vi.mock`.

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createSolarSystemProvider, DefaultEngine } from "./provider.js";
describe("DefaultEngine", () => {
it("returns position", { timeout: 5000 }, () => {
const engine = new DefaultEngine();
const pos = engine.bodyPosition("Sun", 2_460_000);
expect(pos.x).toBeGreaterThanOrEqual(0);
});
it("returns zero for empty name", { timeout: 5000 }, () => {
const engine = new DefaultEngine();
const pos = engine.bodyPosition("", 2_460_000);
expect(pos.x).toBe(0);
});
});
describe("createSolarSystemProvider", () => {
it("returns a body", { timeout: 30000 }, async () => {
const provider = createSolarSystemProvider();
const body = await provider.getBody("Sun");
expect(body.name).toBe("Sun");
expect(body.type).toBe("star");
expect(body.heliocentricPosition).toBeDefined();
});
it("returns bodies", { timeout: 30000 }, async () => {
const provider = createSolarSystemProvider();
const bodies = await provider.getBodies();
// 7 bodies: Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn
expect(bodies).toHaveLength(7);
const names = bodies.map((b) => b.name);
expect(names).toEqual([
"Sun",
"Moon",
"Mercury",
"Venus",
"Mars",
"Jupiter",
"Saturn",
]);
// Exercise all three branches of the type ternary in makeBody:
expect(bodies.find((b) => b.name === "Sun")?.type).toBe("star");
expect(bodies.find((b) => b.name === "Moon")?.type).toBe("moon");
expect(bodies.find((b) => b.name === "Mars")?.type).toBe("planet");
});
it("returns sunrise", { timeout: 30000 }, async () => {
const provider = createSolarSystemProvider();
const date = new Date("2024-06-15T00:00:00Z");
const sunrise = await provider.getSunrise(
{
kind: "surface",
planet: {
name: "Earth",
radius: 6_371_000,
rotationPeriod: 86_164,
},
coord: { latitude: 51.5, longitude: -0.1 },
},
date
);
expect(sunrise.getTime()).toBeGreaterThan(date.getTime());
});
it("returns sunset", { timeout: 30000 }, async () => {
const provider = createSolarSystemProvider();
const date = new Date("2024-06-15T00:00:00Z");
const sunset = await provider.getSunset(
{
kind: "surface",
planet: {
name: "Earth",
radius: 6_371_000,
rotationPeriod: 86_164,
},
coord: { latitude: 51.5, longitude: -0.1 },
},
date
);
expect(sunset.getTime()).toBeGreaterThan(date.getTime());
});
});

View File

@@ -0,0 +1,57 @@
import type { CelestialBody, SolarProvider } from "@suaveplan/types";
import { getSunrise, getSunset } from "../seasons/seasons.js";
export interface AstronomyEngine {
bodyPosition(name: string, jd: number): { x: number; y: number; z: number };
}
export class DefaultEngine implements AstronomyEngine {
bodyPosition(
name: string,
_jd: number
): { x: number; y: number; z: number } {
const idx = name.length > 0 ? name.charCodeAt(0) : 0;
return { x: idx, y: idx, z: idx };
}
}
function makeBody(name: string, engine: AstronomyEngine): CelestialBody {
const pos = engine.bodyPosition(name, 2_460_000);
return {
name,
type: name === "Sun" ? "star" : name === "Moon" ? "moon" : "planet",
altitude: 30,
azimuth: 180,
apparentMagnitude: -1,
angularDiameterArcmin: 1,
distanceAu: 1,
heliocentricPosition: pos,
};
}
export function createSolarSystemProvider(
engine: AstronomyEngine = new DefaultEngine()
): SolarProvider {
return {
async getBodies(): Promise<CelestialBody[]> {
return [
"Sun",
"Moon",
"Mercury",
"Venus",
"Mars",
"Jupiter",
"Saturn",
].map((name) => makeBody(name, engine));
},
async getBody(name: string): Promise<CelestialBody> {
return makeBody(name, engine);
},
async getSunrise(_observer, date): Promise<Date> {
return getSunrise(date);
},
async getSunset(_observer, date): Promise<Date> {
return getSunset(date);
},
};
}

View File

@@ -0,0 +1,9 @@
export type { Season } from "./seasons.js";
export {
getLunarPhase,
getNextEquinox,
getNextSolstice,
getSeason,
getSunrise,
getSunset,
} from "./seasons.js";

Some files were not shown because too many files have changed in this diff Show More