Initial Commit
This commit is contained in:
19
packages/geospatial/geo/README.md
Normal file
19
packages/geospatial/geo/README.md
Normal 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 -->
|
||||
1
packages/geospatial/geo/_intro.md
Normal file
1
packages/geospatial/geo/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Geospatial coordinates, planetary data, and solar-system modelling.
|
||||
31
packages/geospatial/geo/geo-earth/README.md
Normal file
31
packages/geospatial/geo/geo-earth/README.md
Normal 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 });
|
||||
```
|
||||
53
packages/geospatial/geo/geo-earth/package.json
Normal file
53
packages/geospatial/geo/geo-earth/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
formatCoordinate,
|
||||
parseCoordinate,
|
||||
} from "./coordinates.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
EARTH,
|
||||
earthSurface,
|
||||
} from "./earth-config.js";
|
||||
78
packages/geospatial/geo/geo-earth/src/geocoding/geocoding.md
Normal file
78
packages/geospatial/geo/geo-earth/src/geocoding/geocoding.md
Normal 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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
24
packages/geospatial/geo/geo-earth/src/geocoding/geocoding.ts
Normal file
24
packages/geospatial/geo/geo-earth/src/geocoding/geocoding.ts
Normal 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) ?? []);
|
||||
},
|
||||
};
|
||||
}
|
||||
5
packages/geospatial/geo/geo-earth/src/geocoding/index.ts
Normal file
5
packages/geospatial/geo/geo-earth/src/geocoding/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
GeocodingProvider,
|
||||
GeocodingResult,
|
||||
} from "./geocoding.js";
|
||||
export { createInMemoryProvider } from "./geocoding.js";
|
||||
73
packages/geospatial/geo/geo-earth/src/geohash/geohash.md
Normal file
73
packages/geospatial/geo/geo-earth/src/geohash/geohash.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
85
packages/geospatial/geo/geo-earth/src/geohash/geohash.ts
Normal file
85
packages/geospatial/geo/geo-earth/src/geohash/geohash.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
4
packages/geospatial/geo/geo-earth/src/geohash/index.ts
Normal file
4
packages/geospatial/geo/geo-earth/src/geohash/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
decodeGeohash,
|
||||
encodeGeohash,
|
||||
} from "./geohash.js";
|
||||
8
packages/geospatial/geo/geo-earth/src/index.ts
Normal file
8
packages/geospatial/geo/geo-earth/src/index.ts
Normal 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";
|
||||
12
packages/geospatial/geo/geo-earth/tsconfig.lib.json
Normal file
12
packages/geospatial/geo/geo-earth/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
27
packages/geospatial/geo/geo-earth/vite.config.ts
Normal file
27
packages/geospatial/geo/geo-earth/vite.config.ts
Normal 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" })],
|
||||
});
|
||||
10
packages/geospatial/geo/geo-earth/vitest.config.ts
Normal file
10
packages/geospatial/geo/geo-earth/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
coverageThresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
});
|
||||
53
packages/geospatial/geo/geo-planet/README.md
Normal file
53
packages/geospatial/geo/geo-planet/README.md
Normal 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)
|
||||
65
packages/geospatial/geo/geo-planet/package.json
Normal file
65
packages/geospatial/geo/geo-planet/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
airMass,
|
||||
atmosphericDensity,
|
||||
} from "./atmospheric.js";
|
||||
16
packages/geospatial/geo/geo-planet/src/index.ts
Normal file
16
packages/geospatial/geo/geo-planet/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1 @@
|
||||
export { PlanetSurface } from "./planet-surface.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
14
packages/geospatial/geo/geo-planet/src/types/index.ts
Normal file
14
packages/geospatial/geo/geo-planet/src/types/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
AtmosphereConfigSchema,
|
||||
OrbitalConfigSchema,
|
||||
PlanetConfigSchema,
|
||||
PlanetTextureManifestSchema,
|
||||
SatelliteConfigSchema,
|
||||
} from "./schemas.js";
|
||||
export type {
|
||||
AtmosphereConfig,
|
||||
OrbitalConfig,
|
||||
PlanetConfig,
|
||||
PlanetTextureManifest,
|
||||
SatelliteConfig,
|
||||
} from "./types.js";
|
||||
84
packages/geospatial/geo/geo-planet/src/types/schemas.md
Normal file
84
packages/geospatial/geo/geo-planet/src/types/schemas.md
Normal 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.
|
||||
35
packages/geospatial/geo/geo-planet/src/types/schemas.test.ts
Normal file
35
packages/geospatial/geo/geo-planet/src/types/schemas.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
52
packages/geospatial/geo/geo-planet/src/types/schemas.ts
Normal file
52
packages/geospatial/geo/geo-planet/src/types/schemas.ts
Normal 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(),
|
||||
});
|
||||
117
packages/geospatial/geo/geo-planet/src/types/types.md
Normal file
117
packages/geospatial/geo/geo-planet/src/types/types.md
Normal 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.
|
||||
50
packages/geospatial/geo/geo-planet/src/types/types.ts
Normal file
50
packages/geospatial/geo/geo-planet/src/types/types.ts
Normal 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;
|
||||
}
|
||||
12
packages/geospatial/geo/geo-planet/tsconfig.lib.json
Normal file
12
packages/geospatial/geo/geo-planet/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
37
packages/geospatial/geo/geo-planet/vite.config.ts
Normal file
37
packages/geospatial/geo/geo-planet/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
2
packages/geospatial/geo/geo-planet/vitest.config.ts
Normal file
2
packages/geospatial/geo/geo-planet/vitest.config.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
export default createTestConfig();
|
||||
69
packages/geospatial/geo/geo/README.md
Normal file
69
packages/geospatial/geo/geo/README.md
Normal 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
|
||||
85
packages/geospatial/geo/geo/package.json
Normal file
85
packages/geospatial/geo/geo/package.json
Normal 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"]
|
||||
}
|
||||
79
packages/geospatial/geo/geo/src/angular/angular.md
Normal file
79
packages/geospatial/geo/geo/src/angular/angular.md
Normal 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.
|
||||
15
packages/geospatial/geo/geo/src/angular/angular.test.ts
Normal file
15
packages/geospatial/geo/geo/src/angular/angular.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
15
packages/geospatial/geo/geo/src/angular/angular.ts
Normal file
15
packages/geospatial/geo/geo/src/angular/angular.ts
Normal 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)));
|
||||
}
|
||||
1
packages/geospatial/geo/geo/src/angular/index.ts
Normal file
1
packages/geospatial/geo/geo/src/angular/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { angularSeparation } from "./angular.js";
|
||||
55
packages/geospatial/geo/geo/src/bearing/bearing.md
Normal file
55
packages/geospatial/geo/geo/src/bearing/bearing.md
Normal 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`.
|
||||
52
packages/geospatial/geo/geo/src/bearing/bearing.test.ts
Normal file
52
packages/geospatial/geo/geo/src/bearing/bearing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
60
packages/geospatial/geo/geo/src/bearing/bearing.ts
Normal file
60
packages/geospatial/geo/geo/src/bearing/bearing.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
5
packages/geospatial/geo/geo/src/bearing/index.ts
Normal file
5
packages/geospatial/geo/geo/src/bearing/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
bearing,
|
||||
destination,
|
||||
midpoint,
|
||||
} from "./bearing.js";
|
||||
66
packages/geospatial/geo/geo/src/bounds/bounds.md
Normal file
66
packages/geospatial/geo/geo/src/bounds/bounds.md
Normal 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.
|
||||
132
packages/geospatial/geo/geo/src/bounds/bounds.test.ts
Normal file
132
packages/geospatial/geo/geo/src/bounds/bounds.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
73
packages/geospatial/geo/geo/src/bounds/bounds.ts
Normal file
73
packages/geospatial/geo/geo/src/bounds/bounds.ts
Normal 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
|
||||
);
|
||||
}
|
||||
8
packages/geospatial/geo/geo/src/bounds/index.ts
Normal file
8
packages/geospatial/geo/geo/src/bounds/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { BoundingBox } from "./bounds.js";
|
||||
export {
|
||||
containsPoint,
|
||||
createBoundingBox,
|
||||
expandBox,
|
||||
intersects,
|
||||
mergeBoxes,
|
||||
} from "./bounds.js";
|
||||
100
packages/geospatial/geo/geo/src/distance/distance.md
Normal file
100
packages/geospatial/geo/geo/src/distance/distance.md
Normal 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.
|
||||
81
packages/geospatial/geo/geo/src/distance/distance.test.ts
Normal file
81
packages/geospatial/geo/geo/src/distance/distance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
112
packages/geospatial/geo/geo/src/distance/distance.ts
Normal file
112
packages/geospatial/geo/geo/src/distance/distance.ts
Normal 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);
|
||||
}
|
||||
4
packages/geospatial/geo/geo/src/distance/index.ts
Normal file
4
packages/geospatial/geo/geo/src/distance/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
greatCircleDistance,
|
||||
vincentyDistance,
|
||||
} from "./distance.js";
|
||||
51
packages/geospatial/geo/geo/src/ellipsoid/ellipsoid.md
Normal file
51
packages/geospatial/geo/geo/src/ellipsoid/ellipsoid.md
Normal 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.
|
||||
20
packages/geospatial/geo/geo/src/ellipsoid/ellipsoid.test.ts
Normal file
20
packages/geospatial/geo/geo/src/ellipsoid/ellipsoid.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
packages/geospatial/geo/geo/src/ellipsoid/ellipsoid.ts
Normal file
20
packages/geospatial/geo/geo/src/ellipsoid/ellipsoid.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
2
packages/geospatial/geo/geo/src/ellipsoid/index.ts
Normal file
2
packages/geospatial/geo/geo/src/ellipsoid/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { Ellipsoid } from "./ellipsoid.js";
|
||||
export { defineEllipsoid } from "./ellipsoid.js";
|
||||
51
packages/geospatial/geo/geo/src/index.ts
Normal file
51
packages/geospatial/geo/geo/src/index.ts
Normal 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";
|
||||
1
packages/geospatial/geo/geo/src/observability/index.ts
Normal file
1
packages/geospatial/geo/geo/src/observability/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { withSpan } from "./observability.js";
|
||||
@@ -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());
|
||||
```
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
6
packages/geospatial/geo/geo/src/polygon/index.ts
Normal file
6
packages/geospatial/geo/geo/src/polygon/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
pointInPolygon,
|
||||
polygonArea,
|
||||
polygonPerimeter,
|
||||
simplifyPolygon,
|
||||
} from "./polygon.js";
|
||||
63
packages/geospatial/geo/geo/src/polygon/polygon.md
Normal file
63
packages/geospatial/geo/geo/src/polygon/polygon.md
Normal 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.
|
||||
140
packages/geospatial/geo/geo/src/polygon/polygon.test.ts
Normal file
140
packages/geospatial/geo/geo/src/polygon/polygon.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
111
packages/geospatial/geo/geo/src/polygon/polygon.ts
Normal file
111
packages/geospatial/geo/geo/src/polygon/polygon.ts
Normal 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;
|
||||
}
|
||||
11
packages/geospatial/geo/geo/src/transforms/index.ts
Normal file
11
packages/geospatial/geo/geo/src/transforms/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
Mat3x3,
|
||||
SphericalCoord,
|
||||
Vec3,
|
||||
} from "./transforms.js";
|
||||
export {
|
||||
cartesianToSpherical,
|
||||
sphericalToCartesian,
|
||||
toDegrees,
|
||||
toRadians,
|
||||
} from "./transforms.js";
|
||||
59
packages/geospatial/geo/geo/src/transforms/transforms.md
Normal file
59
packages/geospatial/geo/geo/src/transforms/transforms.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
78
packages/geospatial/geo/geo/src/transforms/transforms.ts
Normal file
78
packages/geospatial/geo/geo/src/transforms/transforms.ts
Normal 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 };
|
||||
}
|
||||
16
packages/geospatial/geo/geo/src/types/index.ts
Normal file
16
packages/geospatial/geo/geo/src/types/index.ts
Normal 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";
|
||||
102
packages/geospatial/geo/geo/src/types/schemas.md
Normal file
102
packages/geospatial/geo/geo/src/types/schemas.md
Normal 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.
|
||||
68
packages/geospatial/geo/geo/src/types/schemas.test.ts
Normal file
68
packages/geospatial/geo/geo/src/types/schemas.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
46
packages/geospatial/geo/geo/src/types/schemas.ts
Normal file
46
packages/geospatial/geo/geo/src/types/schemas.ts
Normal 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",
|
||||
]);
|
||||
114
packages/geospatial/geo/geo/src/types/types.md
Normal file
114
packages/geospatial/geo/geo/src/types/types.md
Normal 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.
|
||||
39
packages/geospatial/geo/geo/src/types/types.ts
Normal file
39
packages/geospatial/geo/geo/src/types/types.ts
Normal 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";
|
||||
2
packages/geospatial/geo/geo/src/units/index.ts
Normal file
2
packages/geospatial/geo/geo/src/units/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { DistanceUnit } from "./units.js";
|
||||
export { convertDistance } from "./units.js";
|
||||
95
packages/geospatial/geo/geo/src/units/units.md
Normal file
95
packages/geospatial/geo/geo/src/units/units.md
Normal 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.
|
||||
24
packages/geospatial/geo/geo/src/units/units.test.ts
Normal file
24
packages/geospatial/geo/geo/src/units/units.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
18
packages/geospatial/geo/geo/src/units/units.ts
Normal file
18
packages/geospatial/geo/geo/src/units/units.ts
Normal 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];
|
||||
}
|
||||
12
packages/geospatial/geo/geo/tsconfig.lib.json
Normal file
12
packages/geospatial/geo/geo/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
36
packages/geospatial/geo/geo/vite.config.ts
Normal file
36
packages/geospatial/geo/geo/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
2
packages/geospatial/geo/geo/vitest.config.ts
Normal file
2
packages/geospatial/geo/geo/vitest.config.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
export default createTestConfig();
|
||||
19
packages/geospatial/geo/solar-system/README.md
Normal file
19
packages/geospatial/geo/solar-system/README.md
Normal 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
|
||||
```
|
||||
47
packages/geospatial/geo/solar-system/package.json
Normal file
47
packages/geospatial/geo/solar-system/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
14
packages/geospatial/geo/solar-system/src/index.ts
Normal file
14
packages/geospatial/geo/solar-system/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { AstronomyEngine } from "./provider.js";
|
||||
export {
|
||||
createSolarSystemProvider,
|
||||
DefaultEngine,
|
||||
} from "./provider.js";
|
||||
@@ -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`.
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user