Initial Commit

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

View File

@@ -0,0 +1,13 @@
# `logistics`
Port data and logistics primitives.
<!-- begin:auto-docs -->
## Packages in `logistics`
1 package.
| Package | Description |
|---|---|
| [`@suaveplan/ports`](./ports/README.md) | Global port database with shipping-lane routing, ETA estimation, and UN/LOCODE port lookups for sea ports, airports, and rail terminals. |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Port data and logistics primitives.

View File

@@ -0,0 +1,104 @@
# @suaveplan/ports - Global port database with shipping-lane routing, ETA estimation, and UN/LOCODE port lookups for sea ports, airports, and rail terminals.
Global port database with shipping-lane routing, ETA estimation, and
UN/LOCODE port lookups for sea ports, airports, and rail terminals.
<!-- begin:auto-docs -->
## Documentation tree
- [airports](./src/data/airports/airports.md) — Curated dataset of major international air-cargo airports, exported as the readonly `airports` array. Each record confo…
- [airports](./src/data/airports/airports.md) — Curated dataset of major international air-cargo airports, exported as the readonly `airports` array. Each record confo…
- [all-ports](./src/data/all-ports/all-ports.md) — Aggregates every curated dataset — sea ports, airports, and rail terminals — into a single readonly `allPorts` array an…
- [all-ports](./src/data/all-ports/all-ports.md) — Aggregates every curated dataset — sea ports, airports, and rail terminals — into a single readonly `allPorts` array an…
- [rail-terminals](./src/data/rail-terminals/rail-terminals.md) — Curated dataset of major rail freight terminals — primarily the inland container hubs on the New Silk Road and Trans-Eu…
- [rail-terminals](./src/data/rail-terminals/rail-terminals.md) — Curated dataset of major rail freight terminals — primarily the inland container hubs on the New Silk Road and Trans-Eu…
- [sea-ports](./src/data/sea-ports/sea-ports.md) — Aggregates every regional sea-port dataset (`seaPortsAsia`, `seaPortsEurope`, `seaPortsAmericas`, `seaPortsAfricaMena`,…
- [sea-ports](./src/data/sea-ports/sea-ports.md) — Aggregates every regional sea-port dataset (`seaPortsAsia`, `seaPortsEurope`, `seaPortsAmericas`, `seaPortsAfricaMena`,…
- [sea-ports-africa-mena](./src/data/sea-ports-africa-mena/sea-ports-africa-mena.md) — Curated dataset of major sea ports across Africa and the Middle East / North Africa region, exported as the readonly `s…
- [sea-ports-africa-mena](./src/data/sea-ports-africa-mena/sea-ports-africa-mena.md) — Curated dataset of major sea ports across Africa and the Middle East / North Africa region, exported as the readonly `s…
- [sea-ports-americas](./src/data/sea-ports-americas/sea-ports-americas.md) — Curated dataset of major sea ports across the Americas, exported as the readonly `seaPortsAmericas` array. Every record…
- [sea-ports-americas](./src/data/sea-ports-americas/sea-ports-americas.md) — Curated dataset of major sea ports across the Americas, exported as the readonly `seaPortsAmericas` array. Every record…
- [sea-ports-asia](./src/data/sea-ports-asia/sea-ports-asia.md) — Curated dataset of major sea ports across Asia, exported as the readonly `seaPortsAsia` array. Every record conforms to…
- [sea-ports-asia](./src/data/sea-ports-asia/sea-ports-asia.md) — Curated dataset of major sea ports across Asia, exported as the readonly `seaPortsAsia` array. Every record conforms to…
- [sea-ports-europe](./src/data/sea-ports-europe/sea-ports-europe.md) — Curated dataset of major sea ports across Europe, exported as the readonly `seaPortsEurope` array. Every record conform…
- [sea-ports-europe](./src/data/sea-ports-europe/sea-ports-europe.md) — Curated dataset of major sea ports across Europe, exported as the readonly `seaPortsEurope` array. Every record conform…
- [sea-ports-oceania](./src/data/sea-ports-oceania/sea-ports-oceania.md) — Curated dataset of major sea ports across Oceania, exported as the readonly `seaPortsOceania` array. Every record confo…
- [sea-ports-oceania](./src/data/sea-ports-oceania/sea-ports-oceania.md) — Curated dataset of major sea ports across Oceania, exported as the readonly `seaPortsOceania` array. Every record confo…
- [shipping-lanes](./src/data/shipping-lanes/shipping-lanes.md) — Curated registry of major global shipping lanes — Asia–Europe via Suez, Asia–Europe via Cape, Transpacific, Transatlant…
- [shipping-lanes](./src/data/shipping-lanes/shipping-lanes.md) — Curated registry of major global shipping lanes — Asia–Europe via Suez, Asia–Europe via Cape, Transpacific, Transatlant…
- [distance](./src/distance/distance.md) — Wraps `@suaveplan/geo`'s `greatCircleDistance` against a sphere whose radius matches the IUGG mean Earth radius (6 371…
- [distance](./src/distance/distance.md) — Wraps `@suaveplan/geo`'s `greatCircleDistance` against a sphere whose radius matches the IUGG mean Earth radius (6 371…
- [error-codes](./src/error-codes/error-codes.md) — Declares the eight stable error codes emitted by `@suaveplan/ports` and the matching typed `PortsError` subclasses. Eve…
- [error-codes](./src/error-codes/error-codes.md) — Declares the eight stable error codes emitted by `@suaveplan/ports` and the matching typed `PortsError` subclasses. Eve…
- [eta](./src/eta/eta.md) — Estimates the time of arrival for a vessel travelling along a `ShippingLane`. Resolves an effective cruise speed from (…
- [eta](./src/eta/eta.md) — Estimates the time of arrival for a vessel travelling along a `ShippingLane`. Resolves an effective cruise speed from (…
- [find-port](./src/find-port/find-port.md) — Provides the lookup surface for `@suaveplan/ports`. Every public function consumes the curated `allPorts` registry by d…
- [find-port](./src/find-port/find-port.md) — Provides the lookup surface for `@suaveplan/ports`. Every public function consumes the curated `allPorts` registry by d…
- [lane-distance](./src/lane-distance/lane-distance.md) — Computes great-circle distance metrics for a `ShippingLane`'s waypoint chain. `estimateLaneDistance` sums every segment…
- [lane-distance](./src/lane-distance/lane-distance.md) — Computes great-circle distance metrics for a `ShippingLane`'s waypoint chain. `estimateLaneDistance` sums every segment…
- [lane-lookup](./src/lane-lookup/lane-lookup.md) — Public lookup surface for the curated `shippingLanes` registry. Mirrors the port-lookup module in style: an O(1) `Map`…
- [lane-lookup](./src/lane-lookup/lane-lookup.md) — Public lookup surface for the curated `shippingLanes` registry. Mirrors the port-lookup module in style: an O(1) `Map`…
- [lane-progress](./src/lane-progress/lane-progress.md) — Projects a vessel position onto a shipping lane and reports how much of the lane has been traversed, how much remains,…
- [lane-progress](./src/lane-progress/lane-progress.md) — Projects a vessel position onto a shipping lane and reports how much of the lane has been traversed, how much remains,…
- [nearest-port](./src/nearest-port/nearest-port.md) — Public proximity-search API. `findNearestPort` returns the single closest port to a coordinate; `findPortsInRadius` ret…
- [nearest-port](./src/nearest-port/nearest-port.md) — Public proximity-search API. `findNearestPort` returns the single closest port to a coordinate; `findPortsInRadius` ret…
- [otel](./src/otel/otel.md) — Wires `@suaveplan/ports`' OpenTelemetry instrumentation: three counters that track lookup volume per kind, lane lookup…
- [otel](./src/otel/otel.md) — Wires `@suaveplan/ports`' OpenTelemetry instrumentation: three counters that track lookup volume per kind, lane lookup…
- [schemas](./src/schemas/schemas.md) — Defines Zod schemas for every public type emitted by `@suaveplan/ports`. API plugins, message-queue handlers, and any o…
- [schemas](./src/schemas/schemas.md) — Defines Zod schemas for every public type emitted by `@suaveplan/ports`. API plugins, message-queue handlers, and any o…
- [spatial-index](./src/spatial-index/spatial-index.md) — `PortSpatialIndex` buckets a port set by 5-degree latitude/longitude cells at construction time so `findNearest` and `f…
- [spatial-index](./src/spatial-index/spatial-index.md) — `PortSpatialIndex` buckets a port set by 5-degree latitude/longitude cells at construction time so `findNearest` and `f…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/ports
```
## What's in the box
- Curated `Port` registry: ~100 sea ports across five regions, ~30
international cargo airports, ~30 rail freight terminals.
- Lookups: by UN/LOCODE (`findPort` / `findPortStrict`), by ISO country
(`findPortsByCountry`), by `PortType` (`findPortsByType`), by name
substring (`searchPorts`).
- Proximity: `findNearestPort` and `findPortsInRadius` (5°-bucketed
`PortSpatialIndex` under the hood).
- Curated `shippingLanes` waypoint graph for the major sea, rail, and
air freight routes.
- Lane distance (`estimateLaneDistance`, `cumulativeDistances`), lane
projection (`findPositionOnLane`), and ETA estimation (`estimateETA`).
- 8-code typed error registry under the `PORTS.*` namespace via
`@suaveplan/error`.
- Zod schemas for every public type under `./schemas/schemas`.
- OpenTelemetry instrumentation: three counters and three histograms
via `./otel/otel`.
## Quickstart
```ts
import {
findPort,
findNearestPort,
getShippingLaneStrict,
estimateETA,
} from "@suaveplan/ports";
const shanghai = findPort("CNSHA");
const nearestSea = findNearestPort({ latitude: 1.26, longitude: 103.82 });
const suez = getShippingLaneStrict("asia-europe-suez");
const eta = estimateETA(
{ latitude: 12.5, longitude: 43.3 },
suez,
14,
{ departureTime: new Date() }
);
console.log(eta.etaDate, eta.confidence);
```
## Capability spec
See the canonical capability
specification (requirements + scenarios + `**Verified by:**` links).

View File

@@ -0,0 +1,103 @@
{
"name": "@suaveplan/ports",
"version": "0.1.0",
"description": "Global port database with shipping-lane routing, ETA estimation, and UN/LOCODE port lookups for sea ports, airports, and rail terminals.",
"type": "module",
"sideEffects": false,
"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" }
},
"./constants": {
"import": { "types": "./dist/constants/index.d.ts", "default": "./dist/constants/index.js" }
},
"./error-codes/error-codes": {
"import": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" }
},
"./schemas/schemas": {
"import": { "types": "./dist/schemas/schemas.d.ts", "default": "./dist/schemas/schemas.js" }
},
"./otel/otel": {
"import": { "types": "./dist/otel/otel.d.ts", "default": "./dist/otel/otel.js" }
},
"./distance/distance": {
"import": { "types": "./dist/distance/distance.d.ts", "default": "./dist/distance/distance.js" }
},
"./find-port/find-port": {
"import": { "types": "./dist/find-port/find-port.d.ts", "default": "./dist/find-port/find-port.js" }
},
"./nearest-port/nearest-port": {
"import": { "types": "./dist/nearest-port/nearest-port.d.ts", "default": "./dist/nearest-port/nearest-port.js" }
},
"./spatial-index/spatial-index": {
"import": { "types": "./dist/spatial-index/spatial-index.d.ts", "default": "./dist/spatial-index/spatial-index.js" }
},
"./lane-lookup/lane-lookup": {
"import": { "types": "./dist/lane-lookup/lane-lookup.d.ts", "default": "./dist/lane-lookup/lane-lookup.js" }
},
"./lane-distance/lane-distance": {
"import": { "types": "./dist/lane-distance/lane-distance.d.ts", "default": "./dist/lane-distance/lane-distance.js" }
},
"./lane-progress/lane-progress": {
"import": { "types": "./dist/lane-progress/lane-progress.d.ts", "default": "./dist/lane-progress/lane-progress.js" }
},
"./eta/eta": {
"import": { "types": "./dist/eta/eta.d.ts", "default": "./dist/eta/eta.js" }
},
"./data/sea-ports": {
"import": { "types": "./dist/data/sea-ports/sea-ports.d.ts", "default": "./dist/data/sea-ports/sea-ports.js" }
},
"./data/airports": {
"import": { "types": "./dist/data/airports/airports.d.ts", "default": "./dist/data/airports/airports.js" }
},
"./data/rail-terminals": {
"import": { "types": "./dist/data/rail-terminals/rail-terminals.d.ts", "default": "./dist/data/rail-terminals/rail-terminals.js" }
},
"./data/all-ports": {
"import": { "types": "./dist/data/all-ports/all-ports.d.ts", "default": "./dist/data/all-ports/all-ports.js" }
},
"./data/shipping-lanes": {
"import": { "types": "./dist/data/shipping-lanes/shipping-lanes.d.ts", "default": "./dist/data/shipping-lanes/shipping-lanes.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"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"
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/geo": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,43 @@
import { defineEllipsoid } from "@suaveplan/geo";
/**
* Earth radius in nautical miles, IUGG mean radius (6 371 km / 1.852).
* Used by `estimateETA` to compute fallback distances when a lane's
* cumulative-distance table is degenerate.
*/
export const EARTH_RADIUS_NM = 3_440.065;
/**
* Default conservative cruise speed for a sea-going container vessel,
* used as the last-chance fallback when neither a lane default nor a
* caller override is provided.
*/
export const DEFAULT_SPEED_KNOTS = 12;
/**
* Reasonable cruise-speed defaults by vessel/transport class. Numbers are
* commercial averages, not absolute maxima. Consumers wanting tighter
* planning curves should pass an explicit override to `estimateETA`.
*/
export const VESSEL_CLASS_SPEEDS = {
/** Ultra-large container vessel — typical economic cruise. */
ulcv: 18,
/** Panamax / post-Panamax box ship. */
panamax: 16,
/** Feeder container ship, regional service. */
feeder: 14,
/** Short-sea / coastal trader. */
coastal: 11,
/** Wide-body air freighter, in knots (Mach 0.78 ≈ 470 kt). */
freighterAir: 470,
/** Container rail freight, knots equivalent (80 km/h → 43.2 kt). */
railFreight: 43.2,
} as const satisfies Record<string, number>;
/**
* Spherical earth ellipsoid bound once at module load. Genesis
* `greatCircleDistance` takes an `Ellipsoid` argument and we always pass
* the IUGG mean radius (6 371 km) — this avoids allocating a fresh
* ellipsoid on every call.
*/
export const EARTH_SPHERE = defineEllipsoid(6_371_000, 6_371_000);

View File

@@ -0,0 +1,56 @@
# airports
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Curated dataset of major international air-cargo airports, exported as
the readonly `airports` array. Each record conforms to the `Port`
interface (with `type: "air"`) and carries the IATA code, runway
length, and freight-throughput figures used by ETA estimation on air
shipping lanes and proximity searches in `findNearestPort`.
## Features
- Coverage of the world's busiest cargo hubs across Asia, Europe, North
America, the Middle East, and Latin America.
- Each record carries `iataCode`, `runwayLengthM`, and
`annualThroughputTonnes` so consumers can filter by capacity.
- Facility tags reflect cargo-handling capability (`cargo`,
`intermodal`, `bonded_warehouse`, `free_trade_zone`, `passenger`,
`reefer`).
- IANA timezones populated for every record.
## Usage: Basic
```ts
import { airports } from "@suaveplan/ports/data/airports";
const memphis = airports.find((p) => p.iataCode === "MEM");
```
## Usage: Advanced
```ts
import { airports, findNearestPort } from "@suaveplan/ports";
const nearest = findNearestPort(
{ latitude: 25.2532, longitude: 55.3657 },
airports
);
```
## API Reference
- `airports: readonly Port[]` — curated international air-cargo airport set.
## Implementation Notes
Every entry uses the IATA-prefixed UN/LOCODE convention (e.g. `USMEM`
for Memphis). Throughput figures use underscores as digit separators
for readability. The dataset is curated rather than exhaustive — it
targets the cargo network's primary hubs and the regional gateways
most commonly used in international logistics workflows, not every
licensed airfield.

View File

@@ -0,0 +1,245 @@
import type { Port } from "../../types/port.types.js";
/** Major cargo airports worldwide. */
export const airports: readonly Port[] = [
{
locode: "HKHKG",
name: "Hong Kong Intl",
country: "HK",
coordinate: { latitude: 22.308, longitude: 113.9185 },
type: "air",
timezone: "Asia/Hong_Kong",
facilities: ["cargo", "reefer", "bonded_warehouse"],
iataCode: "HKG",
runwayLengthM: 3800,
annualThroughputTonnes: 4_300_000,
},
{
locode: "CNPVG",
name: "Shanghai Pudong",
country: "CN",
coordinate: { latitude: 31.1434, longitude: 121.8083 },
type: "air",
timezone: "Asia/Shanghai",
facilities: ["cargo", "reefer", "bonded_warehouse"],
iataCode: "PVG",
runwayLengthM: 4000,
annualThroughputTonnes: 3_800_000,
},
{
locode: "AEDXB",
name: "Dubai Intl",
country: "AE",
coordinate: { latitude: 25.2528, longitude: 55.3644 },
type: "air",
timezone: "Asia/Dubai",
facilities: ["cargo", "reefer", "free_trade_zone"],
iataCode: "DXB",
runwayLengthM: 4500,
annualThroughputTonnes: 2_700_000,
},
{
locode: "AEDWC",
name: "Dubai World Central",
country: "AE",
coordinate: { latitude: 24.8966, longitude: 55.1614 },
type: "air",
timezone: "Asia/Dubai",
facilities: ["cargo", "reefer", "free_trade_zone"],
iataCode: "DWC",
runwayLengthM: 4500,
annualThroughputTonnes: 1_100_000,
},
{
locode: "USMEM",
name: "Memphis",
country: "US",
coordinate: { latitude: 35.0421, longitude: -89.9792 },
type: "air",
timezone: "America/Chicago",
facilities: ["cargo", "reefer"],
iataCode: "MEM",
runwayLengthM: 3414,
annualThroughputTonnes: 4_500_000,
},
{
locode: "USSDF",
name: "Louisville",
country: "US",
coordinate: { latitude: 38.174, longitude: -85.7365 },
type: "air",
timezone: "America/New_York",
facilities: ["cargo", "reefer"],
iataCode: "SDF",
runwayLengthM: 3444,
annualThroughputTonnes: 2_900_000,
},
{
locode: "USANC",
name: "Anchorage",
country: "US",
coordinate: { latitude: 61.1743, longitude: -149.9964 },
type: "air",
timezone: "America/Anchorage",
facilities: ["cargo", "reefer"],
iataCode: "ANC",
runwayLengthM: 3231,
annualThroughputTonnes: 2_800_000,
},
{
locode: "JPNRT",
name: "Tokyo Narita",
country: "JP",
coordinate: { latitude: 35.772, longitude: 140.3929 },
type: "air",
timezone: "Asia/Tokyo",
facilities: ["cargo", "reefer", "bonded_warehouse"],
iataCode: "NRT",
runwayLengthM: 4000,
annualThroughputTonnes: 2_200_000,
},
{
locode: "KRICN",
name: "Seoul Incheon",
country: "KR",
coordinate: { latitude: 37.4602, longitude: 126.4407 },
type: "air",
timezone: "Asia/Seoul",
facilities: ["cargo", "reefer", "bonded_warehouse", "free_trade_zone"],
iataCode: "ICN",
runwayLengthM: 4000,
annualThroughputTonnes: 2_900_000,
},
{
locode: "SGSIN",
name: "Singapore Changi",
country: "SG",
coordinate: { latitude: 1.3502, longitude: 103.994 },
type: "air",
timezone: "Asia/Singapore",
facilities: ["cargo", "reefer", "free_trade_zone"],
iataCode: "SIN",
runwayLengthM: 4000,
annualThroughputTonnes: 2_100_000,
},
{
locode: "DEFRM",
name: "Frankfurt Main",
country: "DE",
coordinate: { latitude: 50.0379, longitude: 8.5622 },
type: "air",
timezone: "Europe/Berlin",
facilities: ["cargo", "reefer", "bonded_warehouse"],
iataCode: "FRA",
runwayLengthM: 4000,
annualThroughputTonnes: 2_100_000,
},
{
locode: "GBLHR",
name: "London Heathrow",
country: "GB",
coordinate: { latitude: 51.47, longitude: -0.4543 },
type: "air",
timezone: "Europe/London",
facilities: ["cargo", "reefer", "bonded_warehouse"],
iataCode: "LHR",
runwayLengthM: 3902,
annualThroughputTonnes: 1_500_000,
},
{
locode: "FRCDG",
name: "Paris Charles de Gaulle",
country: "FR",
coordinate: { latitude: 49.0097, longitude: 2.5479 },
type: "air",
timezone: "Europe/Paris",
facilities: ["cargo", "reefer", "bonded_warehouse"],
iataCode: "CDG",
runwayLengthM: 4215,
annualThroughputTonnes: 2_000_000,
},
{
locode: "CNCAN",
name: "Guangzhou Baiyun",
country: "CN",
coordinate: { latitude: 23.3924, longitude: 113.2988 },
type: "air",
timezone: "Asia/Shanghai",
facilities: ["cargo", "reefer"],
iataCode: "CAN",
runwayLengthM: 3800,
annualThroughputTonnes: 2_000_000,
},
{
locode: "TWTPE",
name: "Taipei Taoyuan",
country: "TW",
coordinate: { latitude: 25.0777, longitude: 121.2329 },
type: "air",
timezone: "Asia/Taipei",
facilities: ["cargo", "reefer", "bonded_warehouse"],
iataCode: "TPE",
runwayLengthM: 3660,
annualThroughputTonnes: 2_300_000,
},
{
locode: "NLAMS",
name: "Amsterdam Schiphol",
country: "NL",
coordinate: { latitude: 52.3105, longitude: 4.7683 },
type: "air",
timezone: "Europe/Amsterdam",
facilities: ["cargo", "reefer", "bonded_warehouse"],
iataCode: "AMS",
runwayLengthM: 3800,
annualThroughputTonnes: 1_700_000,
},
{
locode: "USCVG",
name: "Cincinnati",
country: "US",
coordinate: { latitude: 39.0533, longitude: -84.6631 },
type: "air",
timezone: "America/New_York",
facilities: ["cargo"],
iataCode: "CVG",
runwayLengthM: 3749,
annualThroughputTonnes: 1_400_000,
},
{
locode: "INMAA",
name: "Chennai",
country: "IN",
coordinate: { latitude: 12.9941, longitude: 80.1709 },
type: "air",
timezone: "Asia/Kolkata",
facilities: ["cargo", "bonded_warehouse"],
iataCode: "MAA",
runwayLengthM: 3658,
annualThroughputTonnes: 500_000,
},
{
locode: "INDEL",
name: "Delhi Indira Gandhi",
country: "IN",
coordinate: { latitude: 28.5562, longitude: 77.1 },
type: "air",
timezone: "Asia/Kolkata",
facilities: ["cargo", "reefer", "bonded_warehouse"],
iataCode: "DEL",
runwayLengthM: 4430,
annualThroughputTonnes: 1_000_000,
},
{
locode: "QADOH",
name: "Doha Hamad",
country: "QA",
coordinate: { latitude: 25.2609, longitude: 51.6138 },
type: "air",
timezone: "Asia/Qatar",
facilities: ["cargo", "reefer", "free_trade_zone"],
iataCode: "DOH",
runwayLengthM: 4850,
annualThroughputTonnes: 2_500_000,
},
] as const;

View File

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

View File

@@ -0,0 +1,60 @@
# all-ports
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Aggregates every curated dataset — sea ports, airports, and rail
terminals — into a single readonly `allPorts` array and re-exports each
sub-list. This is the default candidate set consumed by `findPort`,
`findNearestPort`, and `findPortsInRadius` when callers do not supply a
custom port set.
## Features
- Single import point for the full curated port universe.
- Each sub-list is re-exported with its original name so consumers can
scope lookups by transport mode.
- Order is `seaPorts → airports → railTerminals`, matching the
main-branch convention; consumers rely on `locode` as the identity.
- All entries conform to the `Port` interface and validate against
`PortSchema`.
## Usage: Basic
```ts
import { allPorts } from "@suaveplan/ports";
console.log("total curated ports:", allPorts.length);
```
## Usage: Advanced
```ts
import {
allPorts,
findPortsByType,
findNearestPort,
} from "@suaveplan/ports";
const air = findPortsByType("air");
const nearestSeaToCargoHub = findNearestPort(
{ latitude: 35.61, longitude: 139.73 },
allPorts
);
```
## API Reference
- `allPorts: readonly Port[]` — combined sea + air + rail set.
- `seaPorts`, `airports`, `railTerminals` — sub-lists re-exported.
## Implementation Notes
Aggregation is computed once at module load via spread. Every lookup
path keys on `locode` so callers can treat the aggregate as a flat
namespace without worrying about cross-modal collisions (UN/LOCODE
allocations are unique per terminal). Sub-list re-exports preserve
referential identity so identity comparisons keep working.

View File

@@ -0,0 +1,13 @@
import type { Port } from "../../types/port.types.js";
import { airports } from "../airports/airports.js";
import { railTerminals } from "../rail-terminals/rail-terminals.js";
import { seaPorts } from "../sea-ports/sea-ports.js";
export { airports, railTerminals, seaPorts };
/** All ports, airports, and rail terminals combined. */
export const allPorts: readonly Port[] = [
...seaPorts,
...airports,
...railTerminals,
];

View File

@@ -0,0 +1 @@
export { airports, allPorts, railTerminals, seaPorts } from "./all-ports.js";

View File

@@ -0,0 +1 @@
export { railTerminals } from "./rail-terminals.js";

View File

@@ -0,0 +1,57 @@
# rail-terminals
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Curated dataset of major rail freight terminals — primarily the
inland container hubs on the New Silk Road and Trans-European rail
freight corridors — exported as the readonly `railTerminals` array.
Each record conforms to the `Port` interface (with `type: "rail"`) and
carries a UIC railway code for cross-system identification.
## Features
- Coverage spans China's interior rail hubs, the Trans-Siberian and
Trans-Mongolian routes, the European TEN-T corridors, and select
North American intermodal centres.
- Each record carries `uicCode` (where assigned) and `iataCode`
(intermodal interchange terminals).
- Facility tags reflect rail-specific capabilities (`intermodal`,
`bonded_warehouse`, `container`, `bulk`, `roro`).
- IANA timezones populated for every record so consumers can derive
local times for cross-border schedules.
## Usage: Basic
```ts
import { railTerminals } from "@suaveplan/ports/data/rail-terminals";
console.log("rail terminal count:", railTerminals.length);
```
## Usage: Advanced
```ts
import { railTerminals, findNearestPort } from "@suaveplan/ports";
const nearest = findNearestPort(
{ latitude: 50.1, longitude: 8.7 }, // Frankfurt
railTerminals
);
```
## API Reference
- `railTerminals: readonly Port[]` — curated rail-freight terminal set.
## Implementation Notes
Every entry uses a UN/LOCODE primary key. `uicCode` is populated only
where the terminal has an assigned UIC code — pure intermodal yards
without a UIC registration leave the field undefined. The dataset is
curated rather than exhaustive: it captures the rail-freight hubs most
likely to appear in cross-modal ETA calculations against this package's
shipping-lane registry.

View File

@@ -0,0 +1,260 @@
import type { Port } from "../../types/port.types.js";
/** Major intermodal rail terminals including Belt and Road corridor hubs. */
export const railTerminals: readonly Port[] = [
// China — Belt and Road origin terminals
{
locode: "CNYIW",
name: "Yiwu",
country: "CN",
coordinate: { latitude: 29.3064, longitude: 120.0753 },
type: "rail",
timezone: "Asia/Shanghai",
facilities: ["intermodal", "container", "bonded_warehouse"],
annualThroughputTEU: 500_000,
},
{
locode: "CNCTU",
name: "Chengdu",
country: "CN",
coordinate: { latitude: 30.5728, longitude: 104.0668 },
type: "rail",
timezone: "Asia/Shanghai",
facilities: ["intermodal", "container", "bonded_warehouse"],
annualThroughputTEU: 300_000,
},
{
locode: "CNCKG",
name: "Chongqing",
country: "CN",
coordinate: { latitude: 29.4316, longitude: 106.9123 },
type: "rail",
timezone: "Asia/Shanghai",
facilities: ["intermodal", "container", "bonded_warehouse"],
annualThroughputTEU: 350_000,
},
{
locode: "CNSIX",
name: "Xi'an",
country: "CN",
coordinate: { latitude: 34.3416, longitude: 108.9398 },
type: "rail",
timezone: "Asia/Shanghai",
facilities: ["intermodal", "container"],
annualThroughputTEU: 400_000,
},
{
locode: "CNZZH",
name: "Zhengzhou",
country: "CN",
coordinate: { latitude: 34.7466, longitude: 113.6253 },
type: "rail",
timezone: "Asia/Shanghai",
facilities: ["intermodal", "container"],
annualThroughputTEU: 200_000,
},
// Central Asia corridor
{
locode: "CNALS",
name: "Alashankou",
country: "CN",
coordinate: { latitude: 45.17, longitude: 82.57 },
type: "rail",
timezone: "Asia/Shanghai",
facilities: ["intermodal", "container"],
annualThroughputTEU: 250_000,
},
{
locode: "CNKHO",
name: "Khorgos (China side)",
country: "CN",
coordinate: { latitude: 44.224, longitude: 80.4108 },
type: "rail",
timezone: "Asia/Shanghai",
facilities: ["intermodal", "container", "free_trade_zone"],
annualThroughputTEU: 300_000,
},
{
locode: "KZKHG",
name: "Khorgos (Kazakhstan)",
country: "KZ",
coordinate: { latitude: 44.2135, longitude: 80.327 },
type: "rail",
timezone: "Asia/Almaty",
facilities: ["intermodal", "container", "free_trade_zone"],
annualThroughputTEU: 300_000,
},
{
locode: "KZDOS",
name: "Dostyk",
country: "KZ",
coordinate: { latitude: 45.51, longitude: 82.5 },
type: "rail",
timezone: "Asia/Almaty",
facilities: ["intermodal", "container"],
annualThroughputTEU: 200_000,
},
{
locode: "KZALA",
name: "Almaty",
country: "KZ",
coordinate: { latitude: 43.222, longitude: 76.8512 },
type: "rail",
timezone: "Asia/Almaty",
facilities: ["intermodal", "container"],
annualThroughputTEU: 150_000,
},
// Russia corridor
{
locode: "RUMOW",
name: "Moscow (Vorsino)",
country: "RU",
coordinate: { latitude: 55.1833, longitude: 36.55 },
type: "rail",
timezone: "Europe/Moscow",
facilities: ["intermodal", "container", "bonded_warehouse"],
annualThroughputTEU: 400_000,
},
{
locode: "RUNVS",
name: "Novosibirsk",
country: "RU",
coordinate: { latitude: 55.0084, longitude: 82.9357 },
type: "rail",
timezone: "Asia/Novosibirsk",
facilities: ["intermodal", "container"],
annualThroughputTEU: 150_000,
},
{
locode: "RUEKT",
name: "Yekaterinburg",
country: "RU",
coordinate: { latitude: 56.8389, longitude: 60.6057 },
type: "rail",
timezone: "Asia/Yekaterinburg",
facilities: ["intermodal", "container"],
annualThroughputTEU: 100_000,
},
// Belarus / Poland border
{
locode: "BYBRS",
name: "Brest",
country: "BY",
coordinate: { latitude: 52.0976, longitude: 23.734 },
type: "rail",
timezone: "Europe/Minsk",
facilities: ["intermodal", "container"],
annualThroughputTEU: 250_000,
},
{
locode: "PLMAL",
name: "Malaszewicze",
country: "PL",
coordinate: { latitude: 51.95, longitude: 23.6333 },
type: "rail",
timezone: "Europe/Warsaw",
facilities: ["intermodal", "container"],
annualThroughputTEU: 250_000,
},
// European terminals
{
locode: "DEDUI",
name: "Duisburg",
country: "DE",
coordinate: { latitude: 51.4344, longitude: 6.7623 },
type: "rail",
timezone: "Europe/Berlin",
facilities: ["intermodal", "container"],
annualThroughputTEU: 800_000,
},
{
locode: "DEHAM",
name: "Hamburg Billwerder",
country: "DE",
coordinate: { latitude: 53.5167, longitude: 10.1 },
type: "rail",
timezone: "Europe/Berlin",
facilities: ["intermodal", "container"],
annualThroughputTEU: 600_000,
},
{
locode: "PLLOD",
name: "Lodz",
country: "PL",
coordinate: { latitude: 51.7592, longitude: 19.456 },
type: "rail",
timezone: "Europe/Warsaw",
facilities: ["intermodal", "container"],
annualThroughputTEU: 200_000,
},
{
locode: "GBBAR",
name: "Barking (London)",
country: "GB",
coordinate: { latitude: 51.5362, longitude: 0.08 },
type: "rail",
timezone: "Europe/London",
facilities: ["intermodal", "container"],
annualThroughputTEU: 150_000,
},
{
locode: "ESLBC",
name: "Barcelona Can Tunis",
country: "ES",
coordinate: { latitude: 41.3497, longitude: 2.1375 },
type: "rail",
timezone: "Europe/Madrid",
facilities: ["intermodal", "container"],
annualThroughputTEU: 100_000,
},
{
locode: "ITMLN",
name: "Milan Segrate",
country: "IT",
coordinate: { latitude: 45.4843, longitude: 9.2573 },
type: "rail",
timezone: "Europe/Rome",
facilities: ["intermodal", "container"],
annualThroughputTEU: 200_000,
},
{
locode: "ATWIE",
name: "Vienna South",
country: "AT",
coordinate: { latitude: 48.1458, longitude: 16.4175 },
type: "rail",
timezone: "Europe/Vienna",
facilities: ["intermodal", "container"],
annualThroughputTEU: 150_000,
},
{
locode: "CZPRA",
name: "Prague Uhrineves",
country: "CZ",
coordinate: { latitude: 50.0166, longitude: 14.5567 },
type: "rail",
timezone: "Europe/Prague",
facilities: ["intermodal", "container"],
annualThroughputTEU: 100_000,
},
{
locode: "HUBUD",
name: "Budapest BILK",
country: "HU",
coordinate: { latitude: 47.4343, longitude: 19.1122 },
type: "rail",
timezone: "Europe/Budapest",
facilities: ["intermodal", "container"],
annualThroughputTEU: 120_000,
},
{
locode: "TRMER",
name: "Mersin",
country: "TR",
coordinate: { latitude: 36.7994, longitude: 34.6332 },
type: "rail",
timezone: "Europe/Istanbul",
facilities: ["intermodal", "container"],
annualThroughputTEU: 100_000,
},
] as const;

View File

@@ -0,0 +1 @@
export { seaPortsAfricaMena } from "./sea-ports-africa-mena.js";

View File

@@ -0,0 +1,60 @@
# sea-ports-africa-mena
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Curated dataset of major sea ports across Africa and the Middle East / North Africa region, exported as the
readonly `seaPortsAfricaMena` array. Every record conforms to the `Port`
interface and the runtime `PortSchema`. The list focuses on
container-handling ports ranked by TEU throughput plus regional
dry-bulk, tanker, and roll-on/roll-off hubs to give realistic coverage
for shipping-lane calculations and proximity searches.
## Features
- ~14 entries covering the major maritime nations of Africa and the Middle East / North Africa region.
- Container throughput hints in TEU (latest published figures at time
of curation).
- Maximum-vessel-size hints in TEU where available.
- Facility tags (container, bulk, tanker, reefer, roro, passenger,
free_trade_zone, bonded_warehouse) reflect real operational
capabilities.
- IANA timezones populated for every record so consumers can derive
local times without lookup tables of their own.
## Usage: Basic
```ts
import { seaPortsAfricaMena } from "@suaveplan/ports/data/sea-ports";
console.log(seaPortsAfricaMena.length, "Africa and the Middle East / North Africa region sea ports");
```
## Usage: Advanced
```ts
import { seaPortsAfricaMena, findNearestPort } from "@suaveplan/ports";
const nearest = findNearestPort(
{ latitude: 51.9, longitude: 4.49 },
seaPortsAfricaMena
);
```
## API Reference
- `seaPortsAfricaMena: readonly Port[]` — curated Africa and the Middle East / North Africa region sea-port set.
## Implementation Notes
Every entry uses a UN/LOCODE primary key. Throughput figures use
underscores as digit separators for readability. The dataset is
intentionally append-only — re-ordering entries would not break
consumers but would generate a misleading diff against the main-branch
source. This is a curated reference set, not a complete UN/LOCODE
mirror; it targets the busiest container ports and the regional hubs
most likely to appear in shipping-lane calculations against this
Africa and the Middle East / North Africa region region.

View File

@@ -0,0 +1,170 @@
import type { Port } from "../../types/port.types.js";
/** Major sea ports in Africa and Middle East / North Africa. */
export const seaPortsAfricaMena: readonly Port[] = [
{
locode: "AEJEA",
name: "Jebel Ali",
country: "AE",
coordinate: { latitude: 25.004, longitude: 55.058 },
type: "sea",
timezone: "Asia/Dubai",
facilities: ["container", "bulk", "tanker", "roro", "free_trade_zone"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 14_100_000,
},
{
locode: "SAKAC",
name: "King Abdullah Port",
country: "SA",
coordinate: { latitude: 22.4107, longitude: 39.18 },
type: "sea",
timezone: "Asia/Riyadh",
facilities: ["container", "bulk", "roro"],
maxVesselSizeTEU: 20000,
annualThroughputTEU: 2_200_000,
},
{
locode: "SAJED",
name: "Jeddah",
country: "SA",
coordinate: { latitude: 21.4925, longitude: 39.1722 },
type: "sea",
timezone: "Asia/Riyadh",
facilities: ["container", "bulk", "tanker", "passenger"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 4_400_000,
},
{
locode: "OMSLL",
name: "Salalah",
country: "OM",
coordinate: { latitude: 16.9425, longitude: 54.0028 },
type: "sea",
timezone: "Asia/Muscat",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 20000,
annualThroughputTEU: 3_500_000,
},
{
locode: "EGALY",
name: "Alexandria",
country: "EG",
coordinate: { latitude: 31.2001, longitude: 29.9187 },
type: "sea",
timezone: "Africa/Cairo",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 1_800_000,
},
{
locode: "ZADUR",
name: "Durban",
country: "ZA",
coordinate: { latitude: -29.8587, longitude: 31.0218 },
type: "sea",
timezone: "Africa/Johannesburg",
facilities: ["container", "bulk", "tanker", "roro"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 2_700_000,
},
{
locode: "DJJIB",
name: "Djibouti",
country: "DJ",
coordinate: { latitude: 11.5952, longitude: 43.148 },
type: "sea",
timezone: "Africa/Djibouti",
facilities: ["container", "bulk", "free_trade_zone"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 1_200_000,
},
{
locode: "NGAPP",
name: "Apapa (Lagos)",
country: "NG",
coordinate: { latitude: 6.4328, longitude: 3.3731 },
type: "sea",
timezone: "Africa/Lagos",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 10000,
annualThroughputTEU: 1_800_000,
},
{
locode: "KEMBA",
name: "Mombasa",
country: "KE",
coordinate: { latitude: -4.043, longitude: 39.6612 },
type: "sea",
timezone: "Africa/Nairobi",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 10000,
annualThroughputTEU: 1_400_000,
},
{
locode: "TZDAR",
name: "Dar es Salaam",
country: "TZ",
coordinate: { latitude: -6.8235, longitude: 39.2695 },
type: "sea",
timezone: "Africa/Dar_es_Salaam",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 8000,
annualThroughputTEU: 600_000,
},
{
locode: "MUPLU",
name: "Port Louis",
country: "MU",
coordinate: { latitude: -20.1605, longitude: 57.5006 },
type: "sea",
timezone: "Indian/Mauritius",
facilities: ["container", "bulk", "free_trade_zone"],
maxVesselSizeTEU: 10000,
annualThroughputTEU: 450_000,
},
{
locode: "IRBND",
name: "Bandar Abbas",
country: "IR",
coordinate: { latitude: 27.1832, longitude: 56.2666 },
type: "sea",
timezone: "Asia/Tehran",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 2_800_000,
},
{
locode: "ILHFA",
name: "Haifa",
country: "IL",
coordinate: { latitude: 32.8192, longitude: 35.0 },
type: "sea",
timezone: "Asia/Jerusalem",
facilities: ["container", "bulk", "tanker", "passenger"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 1_400_000,
},
{
locode: "LBBEY",
name: "Beirut",
country: "LB",
coordinate: { latitude: 33.9006, longitude: 35.5128 },
type: "sea",
timezone: "Asia/Beirut",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 10000,
annualThroughputTEU: 1_200_000,
},
{
locode: "GHTEM",
name: "Tema",
country: "GH",
coordinate: { latitude: 5.6361, longitude: -Math.LN2 },
type: "sea",
timezone: "Africa/Accra",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 8000,
annualThroughputTEU: 1_000_000,
},
] as const;

View File

@@ -0,0 +1 @@
export { seaPortsAmericas } from "./sea-ports-americas.js";

View File

@@ -0,0 +1,60 @@
# sea-ports-americas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Curated dataset of major sea ports across the Americas, exported as the
readonly `seaPortsAmericas` array. Every record conforms to the `Port`
interface and the runtime `PortSchema`. The list focuses on
container-handling ports ranked by TEU throughput plus regional
dry-bulk, tanker, and roll-on/roll-off hubs to give realistic coverage
for shipping-lane calculations and proximity searches.
## Features
- ~20 entries covering the major maritime nations of the Americas.
- Container throughput hints in TEU (latest published figures at time
of curation).
- Maximum-vessel-size hints in TEU where available.
- Facility tags (container, bulk, tanker, reefer, roro, passenger,
free_trade_zone, bonded_warehouse) reflect real operational
capabilities.
- IANA timezones populated for every record so consumers can derive
local times without lookup tables of their own.
## Usage: Basic
```ts
import { seaPortsAmericas } from "@suaveplan/ports/data/sea-ports";
console.log(seaPortsAmericas.length, "the Americas sea ports");
```
## Usage: Advanced
```ts
import { seaPortsAmericas, findNearestPort } from "@suaveplan/ports";
const nearest = findNearestPort(
{ latitude: 51.9, longitude: 4.49 },
seaPortsAmericas
);
```
## API Reference
- `seaPortsAmericas: readonly Port[]` — curated the Americas sea-port set.
## Implementation Notes
Every entry uses a UN/LOCODE primary key. Throughput figures use
underscores as digit separators for readability. The dataset is
intentionally append-only — re-ordering entries would not break
consumers but would generate a misleading diff against the main-branch
source. This is a curated reference set, not a complete UN/LOCODE
mirror; it targets the busiest container ports and the regional hubs
most likely to appear in shipping-lane calculations against this
the Americas region.

View File

@@ -0,0 +1,225 @@
import type { Port } from "../../types/port.types.js";
/** Major sea ports in the Americas. */
export const seaPortsAmericas: readonly Port[] = [
{
locode: "USSAV",
name: "Savannah",
country: "US",
coordinate: { latitude: 32.0809, longitude: -81.0912 },
type: "sea",
timezone: "America/New_York",
facilities: ["container", "bulk", "roro"],
maxVesselSizeTEU: 16000,
annualThroughputTEU: 5_600_000,
},
{
locode: "USLAX",
name: "Los Angeles",
country: "US",
coordinate: { latitude: 33.7406, longitude: -118.2608 },
type: "sea",
timezone: "America/Los_Angeles",
facilities: ["container", "bulk", "tanker", "roro"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 9_900_000,
},
{
locode: "USLGB",
name: "Long Beach",
country: "US",
coordinate: { latitude: 33.7544, longitude: -118.217 },
type: "sea",
timezone: "America/Los_Angeles",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 9_100_000,
},
{
locode: "USNYC",
name: "New York / New Jersey",
country: "US",
coordinate: { latitude: 40.6688, longitude: -74.0445 },
type: "sea",
timezone: "America/New_York",
facilities: ["container", "bulk", "tanker", "roro"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 9_500_000,
},
{
locode: "USHOU",
name: "Houston",
country: "US",
coordinate: { latitude: 29.7321, longitude: -95.2755 },
type: "sea",
timezone: "America/Chicago",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 3_700_000,
},
{
locode: "USSEA",
name: "Seattle-Tacoma",
country: "US",
coordinate: { latitude: 47.27, longitude: -122.4187 },
type: "sea",
timezone: "America/Los_Angeles",
facilities: ["container", "bulk", "roro"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 3_500_000,
},
{
locode: "USOAK",
name: "Oakland",
country: "US",
coordinate: { latitude: 37.7957, longitude: -122.2789 },
type: "sea",
timezone: "America/Los_Angeles",
facilities: ["container", "reefer"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 2_400_000,
},
{
locode: "USCHS",
name: "Charleston",
country: "US",
coordinate: { latitude: 32.7906, longitude: -79.94 },
type: "sea",
timezone: "America/New_York",
facilities: ["container", "roro", "reefer"],
maxVesselSizeTEU: 16000,
annualThroughputTEU: 2_800_000,
},
{
locode: "USNOR",
name: "Norfolk",
country: "US",
coordinate: { latitude: 36.9084, longitude: -76.3336 },
type: "sea",
timezone: "America/New_York",
facilities: ["container", "bulk", "roro"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 3_400_000,
},
{
locode: "PAMIT",
name: "Balboa (Panama)",
country: "PA",
coordinate: { latitude: 8.9511, longitude: -79.5647 },
type: "sea",
timezone: "America/Panama",
facilities: ["container", "free_trade_zone"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 3_200_000,
},
{
locode: "PAONX",
name: "Colon (Panama)",
country: "PA",
coordinate: { latitude: 9.3547, longitude: -79.9006 },
type: "sea",
timezone: "America/Panama",
facilities: ["container", "free_trade_zone"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 4_300_000,
},
{
locode: "BRSSS",
name: "Santos",
country: "BR",
coordinate: { latitude: -23.9535, longitude: -46.3338 },
type: "sea",
timezone: "America/Sao_Paulo",
facilities: ["container", "bulk", "tanker", "reefer"],
maxVesselSizeTEU: 16000,
annualThroughputTEU: 4_700_000,
},
{
locode: "MXLZC",
name: "Lazaro Cardenas",
country: "MX",
coordinate: { latitude: 17.9394, longitude: -102.19 },
type: "sea",
timezone: "America/Mexico_City",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 1_700_000,
},
{
locode: "MXMAN",
name: "Manzanillo",
country: "MX",
coordinate: { latitude: 19.0514, longitude: -104.3183 },
type: "sea",
timezone: "America/Mexico_City",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 3_300_000,
},
{
locode: "CLSAI",
name: "San Antonio",
country: "CL",
coordinate: { latitude: -33.596, longitude: -71.6154 },
type: "sea",
timezone: "America/Santiago",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 1_700_000,
},
{
locode: "COBUN",
name: "Buenaventura",
country: "CO",
coordinate: { latitude: 3.8801, longitude: -77.019 },
type: "sea",
timezone: "America/Bogota",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 10000,
annualThroughputTEU: 1_200_000,
},
{
locode: "CAHAL",
name: "Halifax",
country: "CA",
coordinate: { latitude: 44.6488, longitude: -63.5752 },
type: "sea",
timezone: "America/Halifax",
facilities: ["container", "bulk", "roro"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 580_000,
},
{
locode: "CAVAN",
name: "Vancouver",
country: "CA",
coordinate: { latitude: 49.29, longitude: -123.11 },
type: "sea",
timezone: "America/Vancouver",
facilities: ["container", "bulk", "roro", "tanker"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 3_700_000,
},
{
locode: "CAMTR",
name: "Montreal",
country: "CA",
coordinate: { latitude: 45.5017, longitude: -73.5673 },
type: "sea",
timezone: "America/Toronto",
facilities: ["container", "bulk", "reefer"],
maxVesselSizeTEU: 6000,
annualThroughputTEU: 1_700_000,
},
{
locode: "PECLL",
name: "Callao",
country: "PE",
coordinate: { latitude: -12.06, longitude: -77.15 },
type: "sea",
timezone: "America/Lima",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 2_500_000,
},
] as const;

View File

@@ -0,0 +1 @@
export { seaPortsAsia } from "./sea-ports-asia.js";

View File

@@ -0,0 +1,59 @@
# sea-ports-asia
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Curated dataset of major sea ports across Asia, exported as the readonly
`seaPortsAsia` array. Every record conforms to the `Port` interface and
the runtime `PortSchema`. The list focuses on container-handling ports
ranked by TEU throughput plus regional dry-bulk and tanker hubs to give
realistic coverage for shipping-lane calculations.
## Features
- ~30 entries covering China, Japan, South Korea, Hong Kong, Taiwan,
Singapore, Malaysia, Vietnam, Thailand, Indonesia, the Philippines,
India, Sri Lanka, Bangladesh, and Pakistan.
- Container throughput hints in TEU (latest published figures at time
of curation).
- Maximum-vessel-size hints in TEU.
- Facility tags (container, bulk, tanker, reefer, free_trade_zone,
passenger, roro) reflect real operational capabilities.
- IANA timezones populated for every record so consumers can derive
local times without lookup tables of their own.
## Usage: Basic
```ts
import { seaPortsAsia } from "@suaveplan/ports/data/sea-ports";
console.log(seaPortsAsia.length, "Asia sea ports");
```
## Usage: Advanced
```ts
import { seaPortsAsia, findNearestPort } from "@suaveplan/ports";
const nearest = findNearestPort(
{ latitude: 22.32, longitude: 114.17 },
seaPortsAsia
);
```
## API Reference
- `seaPortsAsia: readonly Port[]` — curated Asia sea-port set.
## Implementation Notes
Every entry uses a UN/LOCODE primary key. Some throughput figures use
underscores as digit separators for readability. The dataset is
intentionally append-only: re-ordering entries would not break
consumers but would generate a misleading diff against the main-branch
source. Curated rather than complete — this is not a substitute for a
live UN/LOCODE database, it's a fixed reference set for ETA and
proximity calculations against the world's busiest container ports.

View File

@@ -0,0 +1,341 @@
import type { Port } from "../../types/port.types.js";
/** Major sea ports in Asia. */
export const seaPortsAsia: readonly Port[] = [
{
locode: "CNSHA",
name: "Shanghai",
country: "CN",
coordinate: { latitude: 31.2304, longitude: 121.4737 },
type: "sea",
timezone: "Asia/Shanghai",
facilities: ["container", "bulk", "tanker", "reefer"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 47_300_000,
},
{
locode: "CNNGB",
name: "Ningbo-Zhoushan",
country: "CN",
coordinate: { latitude: 29.8683, longitude: 121.544 },
type: "sea",
timezone: "Asia/Shanghai",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 33_350_000,
},
{
locode: "CNSZN",
name: "Shenzhen",
country: "CN",
coordinate: { latitude: 22.4813, longitude: 113.9074 },
type: "sea",
timezone: "Asia/Shanghai",
facilities: ["container", "bulk", "reefer"],
maxVesselSizeTEU: 20000,
annualThroughputTEU: 30_040_000,
},
{
locode: "CNQIN",
name: "Qingdao",
country: "CN",
coordinate: { latitude: 36.0671, longitude: 120.3826 },
type: "sea",
timezone: "Asia/Shanghai",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 25_700_000,
},
{
locode: "CNTXG",
name: "Tianjin",
country: "CN",
coordinate: { latitude: 38.986, longitude: 117.7283 },
type: "sea",
timezone: "Asia/Shanghai",
facilities: ["container", "bulk", "tanker", "roro"],
maxVesselSizeTEU: 20000,
annualThroughputTEU: 21_000_000,
},
{
locode: "CNGZG",
name: "Guangzhou",
country: "CN",
coordinate: { latitude: 22.9422, longitude: 113.509 },
type: "sea",
timezone: "Asia/Shanghai",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 24_180_000,
},
{
locode: "CNXMN",
name: "Xiamen",
country: "CN",
coordinate: { latitude: 24.4474, longitude: 118.0894 },
type: "sea",
timezone: "Asia/Shanghai",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 12_050_000,
},
{
locode: "CNDLC",
name: "Dalian",
country: "CN",
coordinate: { latitude: 38.914, longitude: 121.6147 },
type: "sea",
timezone: "Asia/Shanghai",
facilities: ["container", "bulk", "tanker", "roro"],
maxVesselSizeTEU: 15000,
annualThroughputTEU: 8_600_000,
},
{
locode: "HKHKG",
name: "Hong Kong",
country: "HK",
coordinate: { latitude: 22.3193, longitude: 114.1694 },
type: "sea",
timezone: "Asia/Hong_Kong",
facilities: ["container", "bulk", "tanker", "passenger"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 16_100_000,
},
{
locode: "SGSIN",
name: "Singapore",
country: "SG",
coordinate: { latitude: 1.2644, longitude: 103.8198 },
type: "sea",
timezone: "Asia/Singapore",
facilities: [
"container",
"bulk",
"tanker",
"reefer",
"free_trade_zone",
],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 37_300_000,
},
{
locode: "KRPUS",
name: "Busan",
country: "KR",
coordinate: { latitude: 35.1028, longitude: 129.0403 },
type: "sea",
timezone: "Asia/Seoul",
facilities: ["container", "bulk", "tanker", "reefer"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 22_070_000,
},
{
locode: "JPYOK",
name: "Yokohama",
country: "JP",
coordinate: { latitude: 35.4437, longitude: 139.638 },
type: "sea",
timezone: "Asia/Tokyo",
facilities: ["container", "bulk", "roro", "passenger"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 2_410_000,
},
{
locode: "JPUKB",
name: "Kobe",
country: "JP",
coordinate: { latitude: 34.6901, longitude: 135.1956 },
type: "sea",
timezone: "Asia/Tokyo",
facilities: ["container", "bulk", "roro"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 2_780_000,
},
{
locode: "JPTYO",
name: "Tokyo",
country: "JP",
coordinate: { latitude: 35.6528, longitude: 139.8395 },
type: "sea",
timezone: "Asia/Tokyo",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 4_510_000,
},
{
locode: "JPNGO",
name: "Nagoya",
country: "JP",
coordinate: { latitude: 35.088, longitude: 136.881 },
type: "sea",
timezone: "Asia/Tokyo",
facilities: ["container", "bulk", "roro"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 2_640_000,
},
{
locode: "TWKHH",
name: "Kaohsiung",
country: "TW",
coordinate: { latitude: 22.6163, longitude: 120.3042 },
type: "sea",
timezone: "Asia/Taipei",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 9_920_000,
},
{
locode: "MYKLG",
name: "Port Klang",
country: "MY",
coordinate: { latitude: Math.PI, longitude: 101.39 },
type: "sea",
timezone: "Asia/Kuala_Lumpur",
facilities: ["container", "bulk", "tanker", "free_trade_zone"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 13_720_000,
},
{
locode: "MYTPP",
name: "Tanjung Pelepas",
country: "MY",
coordinate: { latitude: 1.3631, longitude: 103.55 },
type: "sea",
timezone: "Asia/Kuala_Lumpur",
facilities: ["container", "free_trade_zone"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 9_850_000,
},
{
locode: "VNHPH",
name: "Hai Phong",
country: "VN",
coordinate: { latitude: 20.8449, longitude: 106.6881 },
type: "sea",
timezone: "Asia/Ho_Chi_Minh",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 5_600_000,
},
{
locode: "VNSGN",
name: "Ho Chi Minh City",
country: "VN",
coordinate: { latitude: 10.7553, longitude: 106.7553 },
type: "sea",
timezone: "Asia/Ho_Chi_Minh",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 8_900_000,
},
{
locode: "THBKK",
name: "Laem Chabang",
country: "TH",
coordinate: { latitude: 13.0658, longitude: 100.8836 },
type: "sea",
timezone: "Asia/Bangkok",
facilities: ["container", "bulk", "roro", "free_trade_zone"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 8_100_000,
},
{
locode: "IDJKT",
name: "Jakarta (Tanjung Priok)",
country: "ID",
coordinate: { latitude: -6.1, longitude: 106.883 },
type: "sea",
timezone: "Asia/Jakarta",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 7_600_000,
},
{
locode: "PHMNL",
name: "Manila",
country: "PH",
coordinate: { latitude: 14.571, longitude: 120.949 },
type: "sea",
timezone: "Asia/Manila",
facilities: ["container", "bulk", "passenger"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 5_100_000,
},
{
locode: "INBOM",
name: "Nhava Sheva (Mumbai)",
country: "IN",
coordinate: { latitude: 18.95, longitude: 72.95 },
type: "sea",
timezone: "Asia/Kolkata",
facilities: ["container", "bulk", "tanker", "reefer"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 5_500_000,
},
{
locode: "INMUN",
name: "Mundra",
country: "IN",
coordinate: { latitude: 22.8397, longitude: 69.7192 },
type: "sea",
timezone: "Asia/Kolkata",
facilities: ["container", "bulk", "tanker", "free_trade_zone"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 6_500_000,
},
{
locode: "INCHN",
name: "Chennai",
country: "IN",
coordinate: { latitude: 13.0827, longitude: 80.2707 },
type: "sea",
timezone: "Asia/Kolkata",
facilities: ["container", "bulk", "roro"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 1_450_000,
},
{
locode: "LKCMB",
name: "Colombo",
country: "LK",
coordinate: { latitude: 6.9555, longitude: 79.8487 },
type: "sea",
timezone: "Asia/Colombo",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 7_250_000,
},
{
locode: "BDCGP",
name: "Chittagong",
country: "BD",
coordinate: { latitude: 22.33, longitude: 91.8 },
type: "sea",
timezone: "Asia/Dhaka",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 5000,
annualThroughputTEU: 3_100_000,
},
{
locode: "PKKAR",
name: "Karachi",
country: "PK",
coordinate: { latitude: 24.8465, longitude: 67.0097 },
type: "sea",
timezone: "Asia/Karachi",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 2_200_000,
},
{
locode: "CNLYG",
name: "Lianyungang",
country: "CN",
coordinate: { latitude: 34.73, longitude: 119.46 },
type: "sea",
timezone: "Asia/Shanghai",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 5_000_000,
},
] as const;

View File

@@ -0,0 +1 @@
export { seaPortsEurope } from "./sea-ports-europe.js";

View File

@@ -0,0 +1,60 @@
# sea-ports-europe
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Curated dataset of major sea ports across Europe, exported as the
readonly `seaPortsEurope` array. Every record conforms to the `Port`
interface and the runtime `PortSchema`. The list focuses on
container-handling ports ranked by TEU throughput plus regional
dry-bulk, tanker, and roll-on/roll-off hubs to give realistic coverage
for shipping-lane calculations and proximity searches.
## Features
- ~24 entries covering the major maritime nations of Europe.
- Container throughput hints in TEU (latest published figures at time
of curation).
- Maximum-vessel-size hints in TEU where available.
- Facility tags (container, bulk, tanker, reefer, roro, passenger,
free_trade_zone, bonded_warehouse) reflect real operational
capabilities.
- IANA timezones populated for every record so consumers can derive
local times without lookup tables of their own.
## Usage: Basic
```ts
import { seaPortsEurope } from "@suaveplan/ports/data/sea-ports";
console.log(seaPortsEurope.length, "Europe sea ports");
```
## Usage: Advanced
```ts
import { seaPortsEurope, findNearestPort } from "@suaveplan/ports";
const nearest = findNearestPort(
{ latitude: 51.9, longitude: 4.49 },
seaPortsEurope
);
```
## API Reference
- `seaPortsEurope: readonly Port[]` — curated Europe sea-port set.
## Implementation Notes
Every entry uses a UN/LOCODE primary key. Throughput figures use
underscores as digit separators for readability. The dataset is
intentionally append-only — re-ordering entries would not break
consumers but would generate a misleading diff against the main-branch
source. This is a curated reference set, not a complete UN/LOCODE
mirror; it targets the busiest container ports and the regional hubs
most likely to appear in shipping-lane calculations against this
Europe region.

View File

@@ -0,0 +1,280 @@
import type { Port } from "../../types/port.types.js";
/** Major sea ports in Europe. */
export const seaPortsEurope: readonly Port[] = [
{
locode: "NLRTM",
name: "Rotterdam",
country: "NL",
coordinate: { latitude: 51.9036, longitude: 4.493 },
type: "sea",
timezone: "Europe/Amsterdam",
facilities: ["container", "bulk", "tanker", "roro", "reefer"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 14_800_000,
},
{
locode: "BEANR",
name: "Antwerp-Bruges",
country: "BE",
coordinate: { latitude: 51.2606, longitude: 4.4025 },
type: "sea",
timezone: "Europe/Brussels",
facilities: ["container", "bulk", "tanker", "roro"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 13_500_000,
},
{
locode: "DEHAM",
name: "Hamburg",
country: "DE",
coordinate: { latitude: 53.5333, longitude: 9.9667 },
type: "sea",
timezone: "Europe/Berlin",
facilities: ["container", "bulk", "roro", "reefer"],
maxVesselSizeTEU: 20000,
annualThroughputTEU: 8_700_000,
},
{
locode: "DEBRV",
name: "Bremerhaven",
country: "DE",
coordinate: { latitude: 53.55, longitude: 8.5833 },
type: "sea",
timezone: "Europe/Berlin",
facilities: ["container", "roro", "reefer"],
maxVesselSizeTEU: 20000,
annualThroughputTEU: 5_000_000,
},
{
locode: "ESVLC",
name: "Valencia",
country: "ES",
coordinate: { latitude: 39.45, longitude: -0.32 },
type: "sea",
timezone: "Europe/Madrid",
facilities: ["container", "bulk", "roro", "passenger"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 5_600_000,
},
{
locode: "ESALG",
name: "Algeciras",
country: "ES",
coordinate: { latitude: 36.1275, longitude: -5.4426 },
type: "sea",
timezone: "Europe/Madrid",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 5_100_000,
},
{
locode: "GRPIR",
name: "Piraeus",
country: "GR",
coordinate: { latitude: 37.9475, longitude: 23.638 },
type: "sea",
timezone: "Europe/Athens",
facilities: ["container", "bulk", "roro", "passenger"],
maxVesselSizeTEU: 20000,
annualThroughputTEU: 5_400_000,
},
{
locode: "ITGOA",
name: "Genoa",
country: "IT",
coordinate: { latitude: 44.4056, longitude: 8.9463 },
type: "sea",
timezone: "Europe/Rome",
facilities: ["container", "bulk", "tanker", "passenger"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 2_600_000,
},
{
locode: "ITGIT",
name: "Gioia Tauro",
country: "IT",
coordinate: { latitude: 38.4237, longitude: 15.8969 },
type: "sea",
timezone: "Europe/Rome",
facilities: ["container"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 3_100_000,
},
{
locode: "GBFXT",
name: "Felixstowe",
country: "GB",
coordinate: { latitude: 51.956, longitude: 1.3513 },
type: "sea",
timezone: "Europe/London",
facilities: ["container", "roro", "reefer"],
maxVesselSizeTEU: 20000,
annualThroughputTEU: 3_600_000,
},
{
locode: "GBSOU",
name: "Southampton",
country: "GB",
coordinate: { latitude: 50.8983, longitude: -1.3964 },
type: "sea",
timezone: "Europe/London",
facilities: ["container", "roro", "passenger"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 1_900_000,
},
{
locode: "GBLON",
name: "London Gateway",
country: "GB",
coordinate: { latitude: 51.5047, longitude: 0.48 },
type: "sea",
timezone: "Europe/London",
facilities: ["container", "reefer"],
maxVesselSizeTEU: 20000,
annualThroughputTEU: 1_800_000,
},
{
locode: "FRLEH",
name: "Le Havre",
country: "FR",
coordinate: { latitude: 49.4833, longitude: 0.1167 },
type: "sea",
timezone: "Europe/Paris",
facilities: ["container", "bulk", "tanker", "roro"],
maxVesselSizeTEU: 20000,
annualThroughputTEU: 2_900_000,
},
{
locode: "FRMRS",
name: "Marseille-Fos",
country: "FR",
coordinate: { latitude: 43.3283, longitude: 4.8486 },
type: "sea",
timezone: "Europe/Paris",
facilities: ["container", "bulk", "tanker", "roro", "passenger"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 1_500_000,
},
{
locode: "PTSIN",
name: "Sines",
country: "PT",
coordinate: { latitude: 37.95, longitude: -8.8667 },
type: "sea",
timezone: "Europe/Lisbon",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 20000,
annualThroughputTEU: 1_700_000,
},
{
locode: "PLGDY",
name: "Gdansk",
country: "PL",
coordinate: { latitude: 54.352, longitude: 18.6466 },
type: "sea",
timezone: "Europe/Warsaw",
facilities: ["container", "bulk", "tanker", "roro"],
maxVesselSizeTEU: 20000,
annualThroughputTEU: 2_100_000,
},
{
locode: "SEGOT",
name: "Gothenburg",
country: "SE",
coordinate: { latitude: 57.6947, longitude: 11.8747 },
type: "sea",
timezone: "Europe/Stockholm",
facilities: ["container", "roro", "bulk"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 780_000,
},
{
locode: "RULED",
name: "St. Petersburg",
country: "RU",
coordinate: { latitude: 59.8983, longitude: 30.2633 },
type: "sea",
timezone: "Europe/Moscow",
facilities: ["container", "bulk", "roro"],
maxVesselSizeTEU: 10000,
annualThroughputTEU: 2_000_000,
},
{
locode: "RUNOG",
name: "Novorossiysk",
country: "RU",
coordinate: { latitude: 44.7167, longitude: 37.7833 },
type: "sea",
timezone: "Europe/Moscow",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 8000,
annualThroughputTEU: 950_000,
},
{
locode: "TRIST",
name: "Istanbul (Ambarli)",
country: "TR",
coordinate: { latitude: 40.9781, longitude: 28.6936 },
type: "sea",
timezone: "Europe/Istanbul",
facilities: ["container", "bulk", "roro"],
maxVesselSizeTEU: 18000,
annualThroughputTEU: 3_400_000,
},
{
locode: "MAPTM",
name: "Tanger Med",
country: "MA",
coordinate: { latitude: 35.885, longitude: -5.5 },
type: "sea",
timezone: "Africa/Casablanca",
facilities: ["container", "roro", "free_trade_zone"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 7_200_000,
},
{
locode: "EGPSD",
name: "Port Said East",
country: "EG",
coordinate: { latitude: 31.2653, longitude: 32.3019 },
type: "sea",
timezone: "Africa/Cairo",
facilities: ["container", "free_trade_zone"],
maxVesselSizeTEU: 24000,
annualThroughputTEU: 3_800_000,
},
{
locode: "MTMAR",
name: "Marsaxlokk",
country: "MT",
coordinate: { latitude: 35.8364, longitude: 14.5428 },
type: "sea",
timezone: "Europe/Malta",
facilities: ["container"],
maxVesselSizeTEU: 20000,
annualThroughputTEU: 2_700_000,
},
{
locode: "GBDVR",
name: "Dover",
country: "GB",
coordinate: { latitude: 51.1279, longitude: 1.3134 },
type: "sea",
timezone: "Europe/London",
facilities: ["roro", "passenger"],
maxVesselSizeTEU: 5000,
annualThroughputTEU: 250_000,
},
{
locode: "GBIMM",
name: "Immingham",
country: "GB",
coordinate: { latitude: 53.6321, longitude: -0.2164 },
type: "sea",
timezone: "Europe/London",
facilities: ["bulk", "tanker", "roro"],
maxVesselSizeTEU: 8000,
annualThroughputTonnes: 46_000_000,
},
] as const;

View File

@@ -0,0 +1 @@
export { seaPortsOceania } from "./sea-ports-oceania.js";

View File

@@ -0,0 +1,60 @@
# sea-ports-oceania
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Curated dataset of major sea ports across Oceania, exported as the
readonly `seaPortsOceania` array. Every record conforms to the `Port`
interface and the runtime `PortSchema`. The list focuses on
container-handling ports ranked by TEU throughput plus regional
dry-bulk, tanker, and roll-on/roll-off hubs to give realistic coverage
for shipping-lane calculations and proximity searches.
## Features
- ~10 entries covering the major maritime nations of Oceania.
- Container throughput hints in TEU (latest published figures at time
of curation).
- Maximum-vessel-size hints in TEU where available.
- Facility tags (container, bulk, tanker, reefer, roro, passenger,
free_trade_zone, bonded_warehouse) reflect real operational
capabilities.
- IANA timezones populated for every record so consumers can derive
local times without lookup tables of their own.
## Usage: Basic
```ts
import { seaPortsOceania } from "@suaveplan/ports/data/sea-ports";
console.log(seaPortsOceania.length, "Oceania sea ports");
```
## Usage: Advanced
```ts
import { seaPortsOceania, findNearestPort } from "@suaveplan/ports";
const nearest = findNearestPort(
{ latitude: 51.9, longitude: 4.49 },
seaPortsOceania
);
```
## API Reference
- `seaPortsOceania: readonly Port[]` — curated Oceania sea-port set.
## Implementation Notes
Every entry uses a UN/LOCODE primary key. Throughput figures use
underscores as digit separators for readability. The dataset is
intentionally append-only — re-ordering entries would not break
consumers but would generate a misleading diff against the main-branch
source. This is a curated reference set, not a complete UN/LOCODE
mirror; it targets the busiest container ports and the regional hubs
most likely to appear in shipping-lane calculations against this
Oceania region.

View File

@@ -0,0 +1,115 @@
import type { Port } from "../../types/port.types.js";
/** Major sea ports in Oceania. */
export const seaPortsOceania: readonly Port[] = [
{
locode: "AUMEL",
name: "Melbourne",
country: "AU",
coordinate: { latitude: -37.8281, longitude: 144.926 },
type: "sea",
timezone: "Australia/Melbourne",
facilities: ["container", "bulk", "roro", "tanker"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 3_000_000,
},
{
locode: "AUSYD",
name: "Sydney",
country: "AU",
coordinate: { latitude: -33.857, longitude: 151.1949 },
type: "sea",
timezone: "Australia/Sydney",
facilities: ["container", "bulk", "tanker"],
maxVesselSizeTEU: 14000,
annualThroughputTEU: 2_500_000,
},
{
locode: "AUBNE",
name: "Brisbane",
country: "AU",
coordinate: { latitude: -27.3639, longitude: 153.12 },
type: "sea",
timezone: "Australia/Brisbane",
facilities: ["container", "bulk", "reefer"],
maxVesselSizeTEU: 10000,
annualThroughputTEU: 1_300_000,
},
{
locode: "AUFRE",
name: "Fremantle",
country: "AU",
coordinate: { latitude: -32.0569, longitude: 115.7439 },
type: "sea",
timezone: "Australia/Perth",
facilities: ["container", "bulk", "roro"],
maxVesselSizeTEU: 10000,
annualThroughputTEU: 780_000,
},
{
locode: "AUADL",
name: "Adelaide",
country: "AU",
coordinate: { latitude: -34.7824, longitude: 138.5097 },
type: "sea",
timezone: "Australia/Adelaide",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 8000,
annualThroughputTEU: 340_000,
},
{
locode: "NZAKL",
name: "Auckland",
country: "NZ",
coordinate: { latitude: -36.8417, longitude: 174.7685 },
type: "sea",
timezone: "Pacific/Auckland",
facilities: ["container", "bulk", "roro"],
maxVesselSizeTEU: 10000,
annualThroughputTEU: 900_000,
},
{
locode: "NZTAU",
name: "Tauranga",
country: "NZ",
coordinate: { latitude: -37.6499, longitude: 176.1833 },
type: "sea",
timezone: "Pacific/Auckland",
facilities: ["container", "bulk", "reefer"],
maxVesselSizeTEU: 10000,
annualThroughputTEU: 1_200_000,
},
{
locode: "PGLAE",
name: "Lae",
country: "PG",
coordinate: { latitude: -6.7333, longitude: 147.0 },
type: "sea",
timezone: "Pacific/Port_Moresby",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 5000,
annualThroughputTEU: 150_000,
},
{
locode: "FJSUV",
name: "Suva",
country: "FJ",
coordinate: { latitude: -18.1416, longitude: 178.4419 },
type: "sea",
timezone: "Pacific/Fiji",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 5000,
annualThroughputTEU: 80_000,
},
{
locode: "NCNOU",
name: "Noumea",
country: "NC",
coordinate: { latitude: -22.2758, longitude: 166.458 },
type: "sea",
timezone: "Pacific/Noumea",
facilities: ["container", "bulk"],
maxVesselSizeTEU: 3000,
annualThroughputTEU: 70_000,
},
] as const;

View File

@@ -0,0 +1,8 @@
export {
seaPorts,
seaPortsAfricaMena,
seaPortsAmericas,
seaPortsAsia,
seaPortsEurope,
seaPortsOceania,
} from "./sea-ports.js";

View File

@@ -0,0 +1,65 @@
# sea-ports
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Aggregates every regional sea-port dataset (`seaPortsAsia`,
`seaPortsEurope`, `seaPortsAmericas`, `seaPortsAfricaMena`,
`seaPortsOceania`) into a single readonly `seaPorts` array and re-exports
each regional sub-list. This is the curated set consumed by
`@suaveplan/ports`' `findPort`, `findNearestPort`, and
`findPortsInRadius` lookups when callers do not pass a custom port set.
## Features
- Single import point for the global sea-port set.
- Every regional sub-list is re-exported with the original name so
callers can scope a search to a region.
- Order is `Asia → Europe → Americas → Africa/MENA → Oceania`, matching
the main-branch convention; consumers rely on `locode` as the
identity rather than array index.
- All entries conform to the `Port` interface and validate against
`PortSchema`.
## Usage: Basic
```ts
import { seaPorts } from "@suaveplan/ports/data/sea-ports";
console.log("global sea-port count:", seaPorts.length);
```
## Usage: Advanced
```ts
import {
seaPortsAsia,
seaPortsEurope,
findPortsInRadius,
} from "@suaveplan/ports";
const pair = [...seaPortsAsia, ...seaPortsEurope];
const candidates = findPortsInRadius(
{ latitude: 36, longitude: 30 },
1500,
pair
);
```
## API Reference
- `seaPorts: readonly Port[]` — global aggregate.
- `seaPortsAsia`, `seaPortsEurope`, `seaPortsAmericas`,
`seaPortsAfricaMena`, `seaPortsOceania` — regional sub-lists
re-exported.
## Implementation Notes
Aggregation is computed once at module load via spread. The cost is
linear in the total port count and pays off immediately because every
lookup-by-locode path hits the merged set. Regional sub-lists remain
referentially distinct from their aggregate appearance, so identity
checks (`port === seaPortsAsia[0]`) keep working after aggregation.

View File

@@ -0,0 +1,23 @@
import type { Port } from "../../types/port.types.js";
import { seaPortsAfricaMena } from "../sea-ports-africa-mena/sea-ports-africa-mena.js";
import { seaPortsAmericas } from "../sea-ports-americas/sea-ports-americas.js";
import { seaPortsAsia } from "../sea-ports-asia/sea-ports-asia.js";
import { seaPortsEurope } from "../sea-ports-europe/sea-ports-europe.js";
import { seaPortsOceania } from "../sea-ports-oceania/sea-ports-oceania.js";
export {
seaPortsAfricaMena,
seaPortsAmericas,
seaPortsAsia,
seaPortsEurope,
seaPortsOceania,
};
/** All major sea ports worldwide, aggregated across every region. */
export const seaPorts: readonly Port[] = [
...seaPortsAsia,
...seaPortsEurope,
...seaPortsAmericas,
...seaPortsAfricaMena,
...seaPortsOceania,
];

View File

@@ -0,0 +1 @@
export { shippingLanes } from "./shipping-lanes.js";

View File

@@ -0,0 +1,57 @@
# shipping-lanes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Curated registry of major global shipping lanes — Asia–Europe via Suez,
Asia–Europe via Cape, Transpacific, Transatlantic, Europe–MENA, plus
rail and air freight lanes — exported as the readonly `shippingLanes`
array. Each lane carries an ordered waypoint chain that ETA, lane
distance, and lane-progress calculations project vessel positions
against.
## Features
- Sea lanes: Asia–Europe via Suez, Asia–Europe via Cape, Transpacific
Asia–US West Coast, Transatlantic Europe–US East Coast.
- Rail lane: China–Europe via Trans-Siberian / Trans-Mongolian.
- Air lane: Asia–Europe.
- Each lane carries `typicalTransitDays` and `typicalSpeedKnots`
(sea/rail) or `typicalSpeedKmh` (air/rail metric).
- Waypoint chains capture strait passages (Malacca, Bab el-Mandeb,
Gibraltar, Suez Canal) so lane-progress reporting reflects real
navigational milestones.
## Usage: Basic
```ts
import { shippingLanes } from "@suaveplan/ports/data/shipping-lanes";
console.log("curated lane count:", shippingLanes.length);
```
## Usage: Advanced
```ts
import { shippingLanes, estimateLaneDistance } from "@suaveplan/ports";
for (const lane of shippingLanes) {
console.log(lane.id, estimateLaneDistance(lane), "nm");
}
```
## API Reference
- `shippingLanes: readonly ShippingLane[]` — curated lane set.
## Implementation Notes
Waypoint chains are coarse — they capture the strategic geography of
the route, not turn-by-turn navigation. Distance and ETA computations
are therefore representative of typical transit, not exact in-water
distance with weather routing. Each lane id is stable across versions
so external systems can reference lanes by string id without worrying
about array reorder.

View File

@@ -0,0 +1,254 @@
import type { ShippingLane } from "../../types/port.types.js";
/** Major global shipping lanes with route waypoints. */
export const shippingLanes: readonly ShippingLane[] = [
{
id: "asia-europe-suez",
name: "Asia to Europe via Suez Canal",
mode: "sea",
typicalTransitDays: 28,
typicalSpeedKnots: 14,
waypoints: [
{ latitude: 31.23, longitude: 121.47 }, // Shanghai
{ latitude: 22.32, longitude: 114.17 }, // Hong Kong
{ latitude: 10.3, longitude: 107.08 }, // South China Sea
{ latitude: 1.26, longitude: 103.82 }, // Singapore Strait
{ latitude: -0.5, longitude: 98.0 }, // Malacca exit
{ latitude: 6.0, longitude: 80.0 }, // Indian Ocean
{ latitude: 12.5, longitude: 43.3 }, // Bab el-Mandeb
{ latitude: 15.6, longitude: 42.0 }, // Red Sea
{ latitude: 27.85, longitude: 34.28 }, // Gulf of Suez
{ latitude: 30.45, longitude: 32.35 }, // Suez Canal
{ latitude: 31.27, longitude: 32.3 }, // Port Said
{ latitude: 35.0, longitude: 24.0 }, // Eastern Mediterranean
{ latitude: 36.0, longitude: 5.0 }, // Western Mediterranean
{ latitude: 36.0, longitude: -5.5 }, // Gibraltar
{ latitude: 43.0, longitude: -9.5 }, // Cape Finisterre
{ latitude: 48.5, longitude: -5.1 }, // Ushant
{ latitude: 51.0, longitude: 1.5 }, // English Channel
{ latitude: 51.9, longitude: 4.49 }, // Rotterdam
],
},
{
id: "asia-europe-cape",
name: "Asia to Europe via Cape of Good Hope",
mode: "sea",
typicalTransitDays: 38,
typicalSpeedKnots: 14,
waypoints: [
{ latitude: 31.23, longitude: 121.47 }, // Shanghai
{ latitude: 1.26, longitude: 103.82 }, // Singapore
{ latitude: -6.0, longitude: 80.0 }, // Indian Ocean
{ latitude: -20.0, longitude: 57.5 }, // Mauritius
{ latitude: -34.35, longitude: 25.6 }, // Cape Agulhas
{ latitude: -33.92, longitude: 18.43 }, // Cape Town
{ latitude: -15.0, longitude: 5.0 }, // South Atlantic
{ latitude: 5.0, longitude: -5.0 }, // Gulf of Guinea
{ latitude: 36.0, longitude: -5.5 }, // Gibraltar
{ latitude: 51.9, longitude: 4.49 }, // Rotterdam
],
},
{
id: "transpacific-asia-uswest",
name: "Transpacific Asia to US West Coast",
mode: "sea",
typicalTransitDays: 14,
typicalSpeedKnots: 18,
waypoints: [
{ latitude: 31.23, longitude: 121.47 }, // Shanghai
{ latitude: 34.5, longitude: 130.0 }, // East China Sea
{ latitude: 38.0, longitude: 145.0 }, // North Pacific
{ latitude: 40.0, longitude: 160.0 }, // Mid-Pacific
{ latitude: 40.0, longitude: 175.0 }, // Date Line
{ latitude: 40.0, longitude: -170.0 }, // East Date Line
{ latitude: 38.0, longitude: -150.0 }, // Northeast Pacific
{ latitude: 35.0, longitude: -130.0 }, // Approach
{ latitude: 33.74, longitude: -118.26 }, // Los Angeles
],
},
{
id: "asia-us-east-panama",
name: "Asia to US East Coast via Panama Canal",
mode: "sea",
typicalTransitDays: 25,
typicalSpeedKnots: 16,
waypoints: [
{ latitude: 31.23, longitude: 121.47 }, // Shanghai
{ latitude: 34.5, longitude: 130.0 }, // East China Sea
{ latitude: 38.0, longitude: 145.0 }, // North Pacific
{ latitude: 33.74, longitude: -118.26 }, // Los Angeles approach
{ latitude: 20.0, longitude: -110.0 }, // Eastern Pacific
{ latitude: 8.95, longitude: -79.56 }, // Panama (Pacific)
{ latitude: 9.35, longitude: -79.9 }, // Panama (Atlantic)
{ latitude: 20.0, longitude: -75.0 }, // Caribbean
{ latitude: 32.0, longitude: -78.0 }, // US East approach
{ latitude: 40.67, longitude: -74.04 }, // New York
],
},
{
id: "transatlantic-europe-useast",
name: "Transatlantic Europe to US East Coast",
mode: "sea",
typicalTransitDays: 10,
typicalSpeedKnots: 18,
waypoints: [
{ latitude: 51.9, longitude: 4.49 }, // Rotterdam
{ latitude: 51.0, longitude: 1.5 }, // English Channel
{ latitude: 50.0, longitude: -5.0 }, // Land's End
{ latitude: 50.0, longitude: -20.0 }, // Mid-Atlantic
{ latitude: 45.0, longitude: -40.0 }, // Western Atlantic
{ latitude: 42.0, longitude: -60.0 }, // Approach
{ latitude: 40.67, longitude: -74.04 }, // New York
],
},
{
id: "intra-asia-china-seasia",
name: "Intra-Asia China to Southeast Asia",
mode: "sea",
typicalTransitDays: 5,
typicalSpeedKnots: 16,
waypoints: [
{ latitude: 31.23, longitude: 121.47 }, // Shanghai
{ latitude: 22.32, longitude: 114.17 }, // Hong Kong
{ latitude: 14.57, longitude: 120.95 }, // Manila
{ latitude: 10.76, longitude: 106.76 }, // Ho Chi Minh
{ latitude: 1.26, longitude: 103.82 }, // Singapore
{ latitude: 3.0, longitude: 101.39 }, // Port Klang
],
},
{
id: "med-feeder",
name: "Mediterranean Feeder",
mode: "sea",
typicalTransitDays: 7,
typicalSpeedKnots: 14,
waypoints: [
{ latitude: 36.13, longitude: -5.44 }, // Algeciras
{ latitude: 36.0, longitude: 0.0 }, // Central Med West
{ latitude: 38.42, longitude: 15.9 }, // Gioia Tauro
{ latitude: 37.95, longitude: 23.64 }, // Piraeus
{ latitude: 36.8, longitude: 34.63 }, // Mersin
{ latitude: 31.27, longitude: 32.3 }, // Port Said
],
},
{
id: "north-sea-baltic",
name: "North Sea and Baltic Feeder",
mode: "sea",
typicalTransitDays: 4,
typicalSpeedKnots: 14,
waypoints: [
{ latitude: 51.9, longitude: 4.49 }, // Rotterdam
{ latitude: 53.55, longitude: 9.97 }, // Hamburg
{ latitude: 54.35, longitude: 18.65 }, // Gdansk
{ latitude: 57.69, longitude: 11.87 }, // Gothenburg
{ latitude: 59.9, longitude: 30.26 }, // St. Petersburg
],
},
{
id: "persian-gulf-asia",
name: "Persian Gulf to East Asia",
mode: "sea",
typicalTransitDays: 16,
typicalSpeedKnots: 14,
waypoints: [
{ latitude: 25.0, longitude: 55.06 }, // Jebel Ali
{ latitude: 24.0, longitude: 58.0 }, // Gulf of Oman
{ latitude: 12.5, longitude: 54.0 }, // Arabian Sea
{ latitude: 6.0, longitude: 80.0 }, // Indian Ocean
{ latitude: 1.26, longitude: 103.82 }, // Singapore
{ latitude: 22.32, longitude: 114.17 }, // Hong Kong
{ latitude: 31.23, longitude: 121.47 }, // Shanghai
],
},
{
id: "east-africa-asia",
name: "East Africa to Asia",
mode: "sea",
typicalTransitDays: 18,
typicalSpeedKnots: 14,
waypoints: [
{ latitude: -29.86, longitude: 31.02 }, // Durban
{ latitude: -12.0, longitude: 45.0 }, // Mozambique Channel
{ latitude: -4.04, longitude: 39.66 }, // Mombasa
{ latitude: 11.6, longitude: 43.15 }, // Djibouti
{ latitude: 6.0, longitude: 80.0 }, // Indian Ocean
{ latitude: 1.26, longitude: 103.82 }, // Singapore
],
},
// Belt and Road rail corridors
{
id: "br-yiwu-london",
name: "Belt and Road: Yiwu to London",
mode: "rail",
typicalTransitDays: 18,
typicalSpeedKmh: 80,
waypoints: [
{ latitude: 29.31, longitude: 120.08 }, // Yiwu
{ latitude: 34.34, longitude: 108.94 }, // Xi'an
{ latitude: 44.22, longitude: 80.41 }, // Khorgos
{ latitude: 43.22, longitude: 76.85 }, // Almaty
{ latitude: 55.01, longitude: 82.94 }, // Novosibirsk
{ latitude: 55.18, longitude: 36.55 }, // Moscow
{ latitude: 52.1, longitude: 23.73 }, // Brest
{ latitude: 51.95, longitude: 23.63 }, // Malaszewicze
{ latitude: 51.43, longitude: 6.76 }, // Duisburg
{ latitude: 51.0, longitude: 1.5 }, // Channel Tunnel
{ latitude: 51.54, longitude: 0.08 }, // London (Barking)
],
},
{
id: "br-chengdu-lodz",
name: "Belt and Road: Chengdu to Lodz",
mode: "rail",
typicalTransitDays: 14,
typicalSpeedKmh: 80,
waypoints: [
{ latitude: 30.57, longitude: 104.07 }, // Chengdu
{ latitude: 34.34, longitude: 108.94 }, // Xi'an
{ latitude: 45.17, longitude: 82.57 }, // Alashankou
{ latitude: 45.51, longitude: 82.5 }, // Dostyk
{ latitude: 55.01, longitude: 82.94 }, // Novosibirsk
{ latitude: 55.18, longitude: 36.55 }, // Moscow
{ latitude: 52.1, longitude: 23.73 }, // Brest
{ latitude: 51.76, longitude: 19.46 }, // Lodz
],
},
{
id: "br-chongqing-duisburg",
name: "Belt and Road: Chongqing to Duisburg",
mode: "rail",
typicalTransitDays: 15,
typicalSpeedKmh: 80,
waypoints: [
{ latitude: 29.43, longitude: 106.91 }, // Chongqing
{ latitude: 34.34, longitude: 108.94 }, // Xi'an
{ latitude: 45.17, longitude: 82.57 }, // Alashankou
{ latitude: 45.51, longitude: 82.5 }, // Dostyk
{ latitude: 55.01, longitude: 82.94 }, // Novosibirsk
{ latitude: 56.84, longitude: 60.61 }, // Yekaterinburg
{ latitude: 55.18, longitude: 36.55 }, // Moscow
{ latitude: 52.1, longitude: 23.73 }, // Brest
{ latitude: 51.95, longitude: 23.63 }, // Malaszewicze
{ latitude: 51.43, longitude: 6.76 }, // Duisburg
],
},
{
id: "br-xian-hamburg",
name: "Belt and Road: Xi'an to Hamburg",
mode: "rail",
typicalTransitDays: 16,
typicalSpeedKmh: 80,
waypoints: [
{ latitude: 34.34, longitude: 108.94 }, // Xi'an
{ latitude: 44.22, longitude: 80.41 }, // Khorgos
{ latitude: 43.22, longitude: 76.85 }, // Almaty
{ latitude: 55.01, longitude: 82.94 }, // Novosibirsk
{ latitude: 55.18, longitude: 36.55 }, // Moscow
{ latitude: 52.1, longitude: 23.73 }, // Brest
{ latitude: 51.95, longitude: 23.63 }, // Malaszewicze
{ latitude: 52.52, longitude: 13.4 }, // Berlin
{ latitude: 53.52, longitude: 10.1 }, // Hamburg
],
},
] as const;

View File

@@ -0,0 +1,69 @@
# distance
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wraps `@suaveplan/geo`'s `greatCircleDistance` against a sphere whose
radius matches the IUGG mean Earth radius (6 371 km), bound once as the
exported `EARTH_SPHERE` ellipsoid. The package's lane-distance,
lane-progress, ETA, and nearest-port modules all consume these wrappers
so the entire package agrees on a single radius and the rounding policy
is centralised.
## Features
- `distanceNm(a, b)` — great-circle distance in nautical miles, rounded
to two decimals.
- `distanceKm(a, b)` — same in kilometres, rounded to two decimals.
- `distanceMetresRaw(a, b)` — unrounded metres, useful for accumulating
segment sums without compounding rounding error.
- Symmetric: `distanceNm(a, b) === distanceNm(b, a)` to floating
precision.
- Non-negative for any valid coordinate pair.
- Coordinate inputs use `@suaveplan/geo`'s `SphericalCoord` shape so the
wrappers are interoperable with the entire geospatial stack.
## Usage: Basic
```ts
import { distanceNm } from "@suaveplan/ports";
const nm = distanceNm(
{ latitude: 1.26, longitude: 103.82 },
{ latitude: 51.9, longitude: 4.49 }
);
console.log(nm); // ~5800 nautical miles
```
## Usage: Advanced
```ts
import { distanceMetresRaw } from "@suaveplan/ports";
function totalMetres(coords: { latitude: number; longitude: number }[]): number {
let total = 0;
for (let i = 0; i < coords.length - 1; i++) {
total += distanceMetresRaw(coords[i], coords[i + 1]);
}
return total;
}
```
## API Reference
- `distanceNm(a, b)` → `number`.
- `distanceKm(a, b)` → `number`.
- `distanceMetresRaw(a, b)` → `number` (unrounded).
## Implementation Notes
All three wrappers route through the single `EARTH_SPHERE` constant so
the cost of ellipsoid construction is paid once at module load. Rounded
helpers multiply by 100, round, then divide — this matches the
main-branch rounding convention so cross-version diffs remain
byte-for-byte stable on the rounded values. `distanceMetresRaw` is
deliberately exposed for callers that need full precision (segment
accumulation in lane-distance, deviation-ratio computation in ETA).

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { distanceKm, distanceMetresRaw, distanceNm } from "./distance.js";
const SINGAPORE = { latitude: 1.2644, longitude: 103.8198 };
const ROTTERDAM = { latitude: 51.9, longitude: 4.49 };
describe("distance helpers", () => {
it("distanceNm zero between identical coords", { timeout: 5000 }, () => {
expect(distanceNm(SINGAPORE, SINGAPORE)).toBe(0);
});
it("distanceKm zero between identical coords", { timeout: 5000 }, () => {
expect(distanceKm(SINGAPORE, SINGAPORE)).toBe(0);
});
it("distanceMetresRaw produces unrounded metres", { timeout: 5000 }, () => {
const raw = distanceMetresRaw(SINGAPORE, ROTTERDAM);
expect(raw).toBeGreaterThan(10_000_000);
expect(raw).toBeLessThan(12_000_000);
});
it("Singapore to Rotterdam great-circle is ~5800 nm", {
timeout: 5000,
}, () => {
const nm = distanceNm(SINGAPORE, ROTTERDAM);
expect(nm).toBeGreaterThan(5600);
expect(nm).toBeLessThan(6000);
});
it("Singapore to Rotterdam great-circle is ~10800 km", {
timeout: 5000,
}, () => {
const km = distanceKm(SINGAPORE, ROTTERDAM);
expect(km).toBeGreaterThan(10500);
expect(km).toBeLessThan(11200);
});
it("nm * 1.852 ≈ km within 1% tolerance", { timeout: 5000 }, () => {
const nm = distanceNm(SINGAPORE, ROTTERDAM);
const km = distanceKm(SINGAPORE, ROTTERDAM);
const expected = nm * 1.852;
expect(Math.abs(km - expected) / km).toBeLessThan(0.01);
});
});

View File

@@ -0,0 +1,31 @@
import type { SphericalCoord } from "@suaveplan/geo";
import { greatCircleDistance } from "@suaveplan/geo";
import { EARTH_SPHERE } from "../constants/index.js";
/**
* Great-circle distance between two coordinates in nautical miles. Uses
* the IUGG mean-radius sphere bound in `EARTH_SPHERE`. The result is
* rounded to two decimal places so equality comparisons remain stable
* across platforms.
*/
export function distanceNm(a: SphericalCoord, b: SphericalCoord): number {
const nm = greatCircleDistance(a, b, EARTH_SPHERE, "nauticalMile");
return Math.round(nm * 100) / 100;
}
/**
* Great-circle distance between two coordinates in kilometres. Mirrors
* `distanceNm` rounding behaviour for the kilometre unit.
*/
export function distanceKm(a: SphericalCoord, b: SphericalCoord): number {
const km = greatCircleDistance(a, b, EARTH_SPHERE, "kilometre");
return Math.round(km * 100) / 100;
}
/** Unrounded raw great-circle distance in metres (for internal use). */
export function distanceMetresRaw(
a: SphericalCoord,
b: SphericalCoord
): number {
return greatCircleDistance(a, b, EARTH_SPHERE, "metre");
}

View File

@@ -0,0 +1 @@
export { distanceKm, distanceMetresRaw, distanceNm } from "./distance.js";

View File

@@ -0,0 +1,96 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the eight stable error codes emitted by `@suaveplan/ports` and
the matching typed `PortsError` subclasses. Every public function in the
package throws one of these subclasses when a precondition fails, so
downstream consumers — loggers, problem+json responders, retry middleware
— can switch on `.code` without re-implementing the classifier. Codes
flow through `defineCodes("PORTS", [...])` from `@suaveplan/error` so the
full namespace is `PORTS.NO_PORTS`, `PORTS.INVALID_RADIUS`, and so on.
## Features
- Eight codes: `NO_PORTS`, `INVALID_RADIUS`,
`INSUFFICIENT_WAYPOINTS`, `INVALID_SPEED`, `MISSING_SPEED`,
`PORT_NOT_FOUND`, `LANE_NOT_FOUND`, `INVALID_LANE`.
- Eight typed subclasses, each extending `PortsError` which itself
extends `SuaveplanError<PortsErrorCode>`.
- Context carriers — `PortsPortNotFoundError` exposes `locode`,
`PortsLaneNotFoundError` exposes `laneId`, `PortsInvalidLaneError`
exposes `issues` (Zod issue array).
- i18n-ready: every constructor accepts a `message` override defaulting
to an i18n key (`ports.errors.<camelCase>`).
- Cause-chain pass-through (`cause`, `traceId`, `context`) honours the
full `SuaveplanErrorOptions` shape.
## Usage: Basic
```ts
import {
PortsNoPortsError,
PortsInvalidRadiusError,
} from "@suaveplan/ports";
function safeFind(): void {
try {
// ...
} catch (err) {
if (err instanceof PortsInvalidRadiusError) {
// 400 Bad Request
} else if (err instanceof PortsNoPortsError) {
// 503 Service Unavailable
}
}
}
```
## Usage: Advanced
```ts
import {
PORTS_CODES,
PortsPortNotFoundError,
} from "@suaveplan/ports";
const err = new PortsPortNotFoundError({
locode: "XXNON",
traceId: "01HXYZ",
context: { source: "rest" },
});
switch (err.code) {
case PORTS_CODES.PORT_NOT_FOUND:
// Render i18n-aware 404 message with locode in context.
break;
default:
throw err;
}
```
## API Reference
- `PORTS_CODES` — frozen registry: `{ NO_PORTS, INVALID_RADIUS,
INSUFFICIENT_WAYPOINTS, INVALID_SPEED, MISSING_SPEED, PORT_NOT_FOUND,
LANE_NOT_FOUND, INVALID_LANE }`.
- `PortsErrorCode` — union type of every code.
- `PortsError` — abstract base.
- `PortsNoPortsError`, `PortsInvalidRadiusError`,
`PortsInsufficientWaypointsError`, `PortsInvalidSpeedError`,
`PortsMissingSpeedError`, `PortsPortNotFoundError`,
`PortsLaneNotFoundError`, `PortsInvalidLaneError`.
## Implementation Notes
The registry is registered eagerly at module load via `defineCodes` so
collisions across packages fail fast. Each subclass forwards the
`pickOpts` filtered options bundle to `SuaveplanError`'s protected
constructor so context, cause, and traceId remain optional but
correctly typed. Default message keys follow `ports.errors.<camelCase>`
so an i18n resolver can translate them at the boundary; consumers that
prefer literal messages pass `{ message: "..." }`.

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PORTS_CODES,
PortsError,
PortsInsufficientWaypointsError,
PortsInvalidLaneError,
PortsInvalidRadiusError,
PortsInvalidSpeedError,
PortsLaneNotFoundError,
PortsMissingSpeedError,
PortsNoPortsError,
PortsPortNotFoundError,
} from "./error-codes.js";
describe("PORTS error codes", () => {
it("registers eight stable codes under the PORTS namespace", {
timeout: 5000,
}, () => {
expect(PORTS_CODES.NO_PORTS).toBe("PORTS.NO_PORTS");
expect(PORTS_CODES.INVALID_RADIUS).toBe("PORTS.INVALID_RADIUS");
expect(PORTS_CODES.INSUFFICIENT_WAYPOINTS).toBe(
"PORTS.INSUFFICIENT_WAYPOINTS"
);
expect(PORTS_CODES.INVALID_SPEED).toBe("PORTS.INVALID_SPEED");
expect(PORTS_CODES.MISSING_SPEED).toBe("PORTS.MISSING_SPEED");
expect(PORTS_CODES.PORT_NOT_FOUND).toBe("PORTS.PORT_NOT_FOUND");
expect(PORTS_CODES.LANE_NOT_FOUND).toBe("PORTS.LANE_NOT_FOUND");
expect(PORTS_CODES.INVALID_LANE).toBe("PORTS.INVALID_LANE");
});
it("every PORTS code has a registered subclass", { timeout: 5000 }, () => {
const instances: PortsError[] = [
new PortsNoPortsError(),
new PortsInvalidRadiusError(),
new PortsInsufficientWaypointsError(),
new PortsInvalidSpeedError(),
new PortsMissingSpeedError(),
new PortsPortNotFoundError({ locode: "ZZZZZ" }),
new PortsLaneNotFoundError({ laneId: "missing" }),
new PortsInvalidLaneError({ issues: [{ path: "speed" }] }),
];
const seen = new Set(instances.map((e) => e.code));
for (const code of Object.values(PORTS_CODES))
expect(seen.has(code)).toBe(true);
for (const instance of instances)
expect(instance).toBeInstanceOf(PortsError);
});
it("PortsPortNotFoundError carries the locode in context", {
timeout: 5000,
}, () => {
const err = new PortsPortNotFoundError({ locode: "XXNON" });
expect(err.context?.["locode"]).toBe("XXNON");
expect(err.code).toBe("PORTS.PORT_NOT_FOUND");
});
it("PortsLaneNotFoundError carries the laneId in context", {
timeout: 5000,
}, () => {
const err = new PortsLaneNotFoundError({ laneId: "missing-lane" });
expect(err.context?.["laneId"]).toBe("missing-lane");
});
it("PortsInvalidLaneError carries Zod issues in context", {
timeout: 5000,
}, () => {
const err = new PortsInvalidLaneError({
issues: [{ path: "id", code: "too_small" }],
});
expect(Array.isArray(err.context?.["issues"])).toBe(true);
});
it("supports custom message + cause + traceId pass-through", {
timeout: 5000,
}, () => {
const cause = new Error("upstream");
const err = new PortsNoPortsError({
message: "no ports indexed",
cause,
traceId: "trace-123",
context: { source: "test" },
});
expect(err.message).toBe("no ports indexed");
expect(err.cause).toBe(cause);
expect(err.traceId).toBe("trace-123");
expect(err.context?.["source"]).toBe("test");
});
it("default-options constructors do not crash", { timeout: 5000 }, () => {
// Ensure every defaulted branch is reachable.
expect(new PortsPortNotFoundError().code).toBe("PORTS.PORT_NOT_FOUND");
expect(new PortsLaneNotFoundError().code).toBe("PORTS.LANE_NOT_FOUND");
expect(new PortsInvalidLaneError().code).toBe("PORTS.INVALID_LANE");
});
});

View File

@@ -0,0 +1,140 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/ports`. Every error
* surfaced by the public functions resolves to exactly one of these
* fully-qualified codes so downstream consumers (loggers, problem+json
* responders, retry middleware) can map to retry policy, HTTP status,
* and i18n keys without re-implementing the classifier.
*/
export const PORTS_CODES = defineCodes("PORTS", [
"NO_PORTS",
"INVALID_RADIUS",
"INSUFFICIENT_WAYPOINTS",
"INVALID_SPEED",
"MISSING_SPEED",
"PORT_NOT_FOUND",
"LANE_NOT_FOUND",
"INVALID_LANE",
] as const);
/** Union of every fully-qualified code emitted by this package. */
export type PortsErrorCode = (typeof PORTS_CODES)[keyof typeof PORTS_CODES];
/** Base class for every typed ports error. */
export abstract class PortsError extends SuaveplanError<PortsErrorCode> {}
interface BaseOpts extends SuaveplanErrorOptions {
readonly message?: string;
}
function pickOpts(opts: BaseOpts): SuaveplanErrorOptions {
const out: {
cause?: unknown;
context?: Readonly<Record<string, unknown>>;
traceId?: string;
} = {};
if (opts.cause !== undefined) out.cause = opts.cause;
if (opts.context !== undefined) out.context = opts.context;
if (opts.traceId !== undefined) out.traceId = opts.traceId;
return out;
}
/** No ports in the search registry. */
export class PortsNoPortsError extends PortsError {
public constructor(opts: BaseOpts = {}) {
super(
PORTS_CODES.NO_PORTS,
opts.message ?? "ports.errors.noPorts",
pickOpts(opts)
);
}
}
/** Non-positive search radius. */
export class PortsInvalidRadiusError extends PortsError {
public constructor(opts: BaseOpts = {}) {
super(
PORTS_CODES.INVALID_RADIUS,
opts.message ?? "ports.errors.invalidRadius",
pickOpts(opts)
);
}
}
/** Lane has fewer than two waypoints. */
export class PortsInsufficientWaypointsError extends PortsError {
public constructor(opts: BaseOpts = {}) {
super(
PORTS_CODES.INSUFFICIENT_WAYPOINTS,
opts.message ?? "ports.errors.insufficientWaypoints",
pickOpts(opts)
);
}
}
/** Resolved speed is non-positive. */
export class PortsInvalidSpeedError extends PortsError {
public constructor(opts: BaseOpts = {}) {
super(
PORTS_CODES.INVALID_SPEED,
opts.message ?? "ports.errors.invalidSpeed",
pickOpts(opts)
);
}
}
/** No speed source resolved (no override, no lane default). */
export class PortsMissingSpeedError extends PortsError {
public constructor(opts: BaseOpts = {}) {
super(
PORTS_CODES.MISSING_SPEED,
opts.message ?? "ports.errors.missingSpeed",
pickOpts(opts)
);
}
}
/** Strict variant of `findPort` did not match. */
export class PortsPortNotFoundError extends PortsError {
public constructor(opts: BaseOpts & { readonly locode?: string } = {}) {
const ctx: Record<string, unknown> = { ...(opts.context ?? {}) };
if (opts.locode !== undefined) ctx["locode"] = opts.locode;
super(
PORTS_CODES.PORT_NOT_FOUND,
opts.message ?? "ports.errors.portNotFound",
{ ...pickOpts(opts), context: ctx }
);
}
}
/** Strict variant of `getShippingLane` did not match. */
export class PortsLaneNotFoundError extends PortsError {
public constructor(opts: BaseOpts & { readonly laneId?: string } = {}) {
const ctx: Record<string, unknown> = { ...(opts.context ?? {}) };
if (opts.laneId !== undefined) ctx["laneId"] = opts.laneId;
super(
PORTS_CODES.LANE_NOT_FOUND,
opts.message ?? "ports.errors.laneNotFound",
{ ...pickOpts(opts), context: ctx }
);
}
}
/** Zod schema rejected a lane payload. */
export class PortsInvalidLaneError extends PortsError {
public constructor(
opts: BaseOpts & {
readonly issues?: ReadonlyArray<Readonly<Record<string, unknown>>>;
} = {}
) {
const ctx: Record<string, unknown> = { ...(opts.context ?? {}) };
if (opts.issues !== undefined) ctx["issues"] = opts.issues;
super(
PORTS_CODES.INVALID_LANE,
opts.message ?? "ports.errors.invalidLane",
{ ...pickOpts(opts), context: ctx }
);
}
}

View File

@@ -0,0 +1,13 @@
export {
PORTS_CODES,
PortsError,
type PortsErrorCode,
PortsInsufficientWaypointsError,
PortsInvalidLaneError,
PortsInvalidRadiusError,
PortsInvalidSpeedError,
PortsLaneNotFoundError,
PortsMissingSpeedError,
PortsNoPortsError,
PortsPortNotFoundError,
} from "./error-codes.js";

View File

@@ -0,0 +1,70 @@
# eta
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Estimates the time of arrival for a vessel travelling along a
`ShippingLane`. Resolves an effective cruise speed from (in order) an
explicit override, the lane's `typicalSpeedKnots`, or the km/h
equivalent, then divides the remaining-distance figure produced by
`findPositionOnLane` to compute `etaHours` and projects to an
`etaDate`. The result includes a `confidence` band derived from
deviation between travelled+remaining and the lane's total length so
operators can flag vessels that have drifted off the planned route.
## Features
- Three-tier speed resolution: explicit override → lane knots → lane
km/h (converted at 1.852 km/nm).
- Injectable wall-clock — `opts.clock?: Clock` defaults to `Date.now`.
- Explicit `opts.departureTime?` overrides the clock.
- Confidence band: `high` (off-lane ≤3% of total length), `medium`
(≤10%), `low` (more than 10%).
- Throws `PortsInvalidSpeedError` on non-positive speed, and
`PortsMissingSpeedError` when no speed source resolves.
- Emits `ports.eta_total{confidence}` counter and
`ports.eta_duration_ms` histogram per call.
- `etaHours` rounded to two decimals; `etaDate` uses unrounded hours
for sub-minute precision.
## Usage: Basic
```ts
import { estimateETA, getShippingLaneStrict } from "@suaveplan/ports";
const suez = getShippingLaneStrict("asia-europe-suez");
const result = estimateETA({ latitude: 12.5, longitude: 43.3 }, suez);
console.log(result.etaDate, result.confidence);
```
## Usage: Advanced
```ts
import { estimateETA } from "@suaveplan/ports";
import { createClock } from "@suaveplan/testing";
const clock = createClock(new Date("2026-05-25T00:00:00Z"));
const eta = estimateETA(coord, lane, 18, {
clock: () => clock.now(),
});
```
## API Reference
- `estimateETA(currentCoord, lane, avgSpeedKnots?, opts?)` → `ETAEstimate`.
- `EstimateETAOptions`:
- `departureTime?: Date` — explicit override.
- `clock?: Clock` — wall-clock source (`() => number`).
## Implementation Notes
Speed resolution is centralised in a private helper so the order of
fallbacks stays explicit. The `etaDate` is computed from the unrounded
hour figure — rounding to two decimals before `* 3_600_000` would
quantise arrival to 36-second buckets. The deviation ratio uses the
lane's full great-circle length as the denominator so the band stays
calibrated independent of vessel position. `performance.now()` is
preferred over `Date.now()` for the duration histogram when available.

View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { shippingLanes } from "../data/shipping-lanes/shipping-lanes.js";
import {
PortsInvalidSpeedError,
PortsMissingSpeedError,
} from "../error-codes/error-codes.js";
import type { ShippingLane } from "../types/port.types.js";
import { estimateETA } from "./eta.js";
const SUEZ = shippingLanes.find((l) => l.id === "asia-europe-suez")!;
describe("estimateETA", () => {
it("estimates ETA using lane default speed", { timeout: 5000 }, () => {
const result = estimateETA(SUEZ.waypoints[0]!, SUEZ);
expect(result.etaHours).toBeGreaterThan(0);
expect(result.confidence).toBe("high");
expect(result.etaDate).toBeInstanceOf(Date);
});
it("injectable clock controls departure time", { timeout: 5000 }, () => {
const fixed = 0;
const result = estimateETA(SUEZ.waypoints[0]!, SUEZ, 14, {
clock: () => fixed,
});
// etaHours rounded — use the unrounded remainingDistance / speed.
const rawHours = result.remainingDistanceNm / 14;
expect(
Math.abs(result.etaDate.getTime() - rawHours * 3_600_000)
).toBeLessThan(2);
});
it("departureTime override wins over clock", { timeout: 5000 }, () => {
const dep = new Date("2026-01-01T00:00:00.000Z");
const result = estimateETA(SUEZ.waypoints[0]!, SUEZ, 14, {
departureTime: dep,
clock: () => 0,
});
expect(result.etaDate.getTime()).toBeGreaterThan(dep.getTime());
});
it("uses Date.now when neither departure nor clock supplied", {
timeout: 5000,
}, () => {
const before = Date.now();
const result = estimateETA(SUEZ.waypoints[0]!, SUEZ, 14);
const after = Date.now();
// The ETA date uses unrounded hours, while `etaHours` is rounded to 2 dp.
// Use the rounded figure as a lower bound (worst case: hours rounded down
// to a smaller magnitude) with a 1-hour slack to cover the rounding gap.
const slackMs = 3_600_000;
const expectedMs = result.etaHours * 3_600_000;
expect(result.etaDate.getTime()).toBeGreaterThanOrEqual(
before + expectedMs - slackMs
);
expect(result.etaDate.getTime()).toBeLessThanOrEqual(
after + expectedMs + slackMs
);
});
it("throws PORTS.INVALID_SPEED on non-positive override", {
timeout: 5000,
}, () => {
expect(() => estimateETA(SUEZ.waypoints[0]!, SUEZ, 0)).toThrow(
PortsInvalidSpeedError
);
expect(() => estimateETA(SUEZ.waypoints[0]!, SUEZ, -5)).toThrow(
PortsInvalidSpeedError
);
});
it("throws PORTS.INVALID_SPEED when lane has zero typical knots", {
timeout: 5000,
}, () => {
const broken: ShippingLane = { ...SUEZ, typicalSpeedKnots: 0 };
expect(() => estimateETA(SUEZ.waypoints[0]!, broken)).toThrow(
PortsInvalidSpeedError
);
});
it("throws PORTS.INVALID_SPEED when lane has zero kmh", {
timeout: 5000,
}, () => {
const broken: ShippingLane = {
...SUEZ,
typicalSpeedKnots: undefined,
typicalSpeedKmh: 0,
};
expect(() => estimateETA(SUEZ.waypoints[0]!, broken)).toThrow(
PortsInvalidSpeedError
);
});
it("falls back to km/h when only typicalSpeedKmh is set", {
timeout: 5000,
}, () => {
const railLike: ShippingLane = {
...SUEZ,
typicalSpeedKnots: undefined,
typicalSpeedKmh: 80,
};
const result = estimateETA(SUEZ.waypoints[0]!, railLike);
expect(result.etaHours).toBeGreaterThan(0);
});
it("throws PORTS.MISSING_SPEED when no speed source resolves", {
timeout: 5000,
}, () => {
const noSpeed: ShippingLane = {
...SUEZ,
typicalSpeedKnots: undefined,
typicalSpeedKmh: undefined,
};
expect(() => estimateETA(SUEZ.waypoints[0]!, noSpeed)).toThrow(
PortsMissingSpeedError
);
});
it("confidence is high when on the lane", { timeout: 5000 }, () => {
const result = estimateETA(SUEZ.waypoints[5]!, SUEZ, 14);
expect(result.confidence).toBe("high");
});
it("confidence is low for vessels far off-lane", { timeout: 5000 }, () => {
// Deep South Pacific — thousands of nm from any Suez waypoint.
const result = estimateETA(
{ latitude: -50, longitude: -100 },
SUEZ,
14
);
expect(result.confidence).toBe("low");
});
it("zero-length lane yields high confidence (totalDistance fallback branch)", {
timeout: 5000,
}, () => {
const degenerate: ShippingLane = {
id: "deg",
name: "deg",
mode: "sea",
waypoints: [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 0 },
],
typicalSpeedKnots: 14,
};
const result = estimateETA({ latitude: 0, longitude: 0 }, degenerate);
expect(result.confidence).toBe("high");
expect(result.remainingDistanceNm).toBe(0);
});
it("confidence is medium for moderate off-lane deviation", {
timeout: 5000,
}, () => {
// Synthesise a long lane and place the vessel ~5% off-route.
const longLane: ShippingLane = {
id: "synthetic",
name: "synthetic",
mode: "sea",
waypoints: [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 60 },
],
typicalSpeedKnots: 14,
};
// 1° latitude ≈ 60 nm; total lane is ~3600 nm; 5% → ~180 nm → 3°
// away from the START waypoint (which the nearest-waypoint scan picks).
const result = estimateETA({ latitude: 3, longitude: 0 }, longLane);
expect(result.confidence).toBe("medium");
});
});

View File

@@ -0,0 +1,86 @@
import type { SphericalCoord } from "@suaveplan/geo";
import type { Clock } from "@suaveplan/types";
import { distanceNm } from "../distance/distance.js";
import {
PortsInvalidSpeedError,
PortsMissingSpeedError,
} from "../error-codes/error-codes.js";
import { estimateLaneDistance } from "../lane-distance/lane-distance.js";
import { findPositionOnLane } from "../lane-progress/lane-progress.js";
import { recordEta, recordEtaDuration } from "../otel/otel.js";
import type { ETAEstimate, ShippingLane } from "../types/port.types.js";
/** Options bundle for `estimateETA`. */
export interface EstimateETAOptions {
/** Explicit departure time override. Defaults to `clock()` or `Date.now()`. */
readonly departureTime?: Date;
/** Wall-clock source. Falls back to `Date.now` when omitted. */
readonly clock?: Clock;
}
function resolveSpeed(lane: ShippingLane, override?: number): number {
if (override !== undefined) {
if (override <= 0) throw new PortsInvalidSpeedError();
return override;
}
if (lane.typicalSpeedKnots !== undefined) {
if (lane.typicalSpeedKnots <= 0) throw new PortsInvalidSpeedError();
return lane.typicalSpeedKnots;
}
if (lane.typicalSpeedKmh !== undefined) {
if (lane.typicalSpeedKmh <= 0) throw new PortsInvalidSpeedError();
return lane.typicalSpeedKmh / 1.852;
}
throw new PortsMissingSpeedError();
}
/**
* Estimate the time of arrival for a vessel on a shipping lane. Speed
* is resolved from (in order) the explicit override, the lane's
* `typicalSpeedKnots`, or the km/h equivalent. The result includes a
* confidence band ("high", "medium", "low") derived from the deviation
* between travelled+remaining and the lane's total great-circle length.
*/
export function estimateETA(
currentCoord: SphericalCoord,
lane: ShippingLane,
avgSpeedKnots?: number,
options: EstimateETAOptions = {}
): ETAEstimate {
const t0 = performance.now();
const speed = resolveSpeed(lane, avgSpeedKnots);
const progress = findPositionOnLane(currentCoord, lane);
const { remainingDistanceNm } = progress;
const etaHoursRaw = remainingDistanceNm / speed;
const etaHours = Math.round(etaHoursRaw * 100) / 100;
const baseMs =
options.departureTime?.getTime() ??
(options.clock !== undefined ? options.clock() : Date.now());
const etaDate = new Date(baseMs + etaHoursRaw * 3_600_000);
const totalDistance = estimateLaneDistance(lane);
// Confidence band: ratio of the perpendicular distance from `currentCoord`
// to its nearest lane waypoint against the lane's total great-circle
// length. A vessel that has strayed >10% of total lane length off the
// route gets a "low" confidence band; 3–10% is "medium"; <3% is "high".
const offLaneNm = distanceNm(
currentCoord,
lane.waypoints[progress.nearestWaypointIndex] as SphericalCoord
);
const deviationRatio = totalDistance > 0 ? offLaneNm / totalDistance : 0;
let confidence: ETAEstimate["confidence"] = "high";
if (deviationRatio > 0.1) confidence = "low";
else if (deviationRatio > 0.03) confidence = "medium";
recordEta(confidence);
recordEtaDuration(performance.now() - t0);
return {
remainingDistanceNm,
etaHours,
etaDate,
confidence,
};
}

View File

@@ -0,0 +1 @@
export { type EstimateETAOptions, estimateETA } from "./eta.js";

View File

@@ -0,0 +1,78 @@
# find-port
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the lookup surface for `@suaveplan/ports`. Every public
function consumes the curated `allPorts` registry by default but accepts
an explicit `ports` argument so consumers can scope a search to a
subset (a tenant-specific allowlist, an environment-restricted set, or
test fixtures). UN/LOCODE lookups go through a lazily-built `Map`
index keyed on the uppercased locode so the call cost is O(1) once the
index is initialised.
## Features
- `findPort(locode)` — O(1) lookup by UN/LOCODE, case-insensitive,
returns `undefined` on miss.
- `findPortStrict(locode)` — throws `PortsPortNotFoundError`
(`PORTS.PORT_NOT_FOUND`) on miss, carrying the locode in error
context.
- `findPortsByCountry(country, ports?)` — ISO 3166-1 alpha-2 filter
(input uppercased).
- `findPortsByType(type, ports?)` — `PortType` filter.
- `searchPorts(query, ports?)` — case-insensitive substring match on
`name`.
- Every call emits the OTel counter `ports.lookup_total{kind}`.
## Usage: Basic
```ts
import { findPort, findPortsByCountry } from "@suaveplan/ports";
const shanghai = findPort("cnsha");
const dutchPorts = findPortsByCountry("nl");
```
## Usage: Advanced
```ts
import {
findPortStrict,
findPortsByType,
searchPorts,
PortsPortNotFoundError,
} from "@suaveplan/ports";
try {
const port = findPortStrict("ZZZZZ");
} catch (err) {
if (err instanceof PortsPortNotFoundError) {
// 404
}
}
const sea = findPortsByType("sea");
const named = searchPorts("rotterdam", sea); // scoped search
```
## API Reference
- `findPort(locode: string): Port | undefined`.
- `findPortStrict(locode: string): Port` — throws on miss.
- `findPortsByCountry(country: string, ports?: readonly Port[]): Port[]`.
- `findPortsByType(type: PortType, ports?: readonly Port[]): Port[]`.
- `searchPorts(query: string, ports?: readonly Port[]): Port[]`.
## Implementation Notes
The locode index is built lazily on first lookup and cached at module
scope for the lifetime of the process. Re-importing the module never
duplicates the index because ES modules are singletons within a realm.
Filtering helpers return fresh arrays so callers can mutate without
affecting the curated registry. The substring match is allocator-light
— it lowercases the query once and the port name once per record, no
regex compilation in the hot path.

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PortsPortNotFoundError } from "../error-codes/error-codes.js";
import {
findPort,
findPortStrict,
findPortsByCountry,
findPortsByType,
searchPorts,
} from "./find-port.js";
describe("findPort", () => {
it("finds CNSHA via case-insensitive lookup", { timeout: 5000 }, () => {
const port = findPort("cnsha");
expect(port?.locode).toBe("CNSHA");
expect(port?.country).toBe("CN");
expect(port?.type).toBe("sea");
});
it("returns undefined on miss", { timeout: 5000 }, () => {
expect(findPort("XXNON")).toBeUndefined();
});
it("strict variant throws on miss", { timeout: 5000 }, () => {
expect(() => findPortStrict("XXNON")).toThrow(PortsPortNotFoundError);
});
it("strict variant returns the port on hit", { timeout: 5000 }, () => {
const port = findPortStrict("SGSIN");
expect(port.locode).toBe("SGSIN");
});
});
describe("findPortsByCountry", () => {
it("filters by country", { timeout: 5000 }, () => {
const ports = findPortsByCountry("nl");
expect(ports.length).toBeGreaterThan(0);
for (const port of ports) expect(port.country).toBe("NL");
expect(ports.some((p) => p.locode === "NLRTM")).toBe(true);
});
it("respects custom port set", { timeout: 5000 }, () => {
const custom = findPortsByCountry("ZZ", []);
expect(custom).toEqual([]);
});
});
describe("findPortsByType", () => {
it("filters by sea type", { timeout: 5000 }, () => {
const ports = findPortsByType("sea");
expect(ports.length).toBeGreaterThan(0);
for (const port of ports) expect(port.type).toBe("sea");
});
it("filters by air type", { timeout: 5000 }, () => {
const ports = findPortsByType("air");
for (const port of ports) expect(port.type).toBe("air");
});
it("filters by rail type", { timeout: 5000 }, () => {
const ports = findPortsByType("rail");
for (const port of ports) expect(port.type).toBe("rail");
});
});
describe("searchPorts", () => {
it("matches case-insensitive substring on name", { timeout: 5000 }, () => {
const ports = searchPorts("singapore");
expect(ports.some((p) => p.locode === "SGSIN")).toBe(true);
});
it("returns empty on no match", { timeout: 5000 }, () => {
expect(searchPorts("absolutely-not-a-port")).toEqual([]);
});
});

View File

@@ -0,0 +1,68 @@
import { allPorts } from "../data/all-ports/all-ports.js";
import { PortsPortNotFoundError } from "../error-codes/error-codes.js";
import { recordPortLookup } from "../otel/otel.js";
import type { Port, PortType } from "../types/port.types.js";
/** Lookup index built lazily on first use. */
let locodeIndex: Map<string, Port> | null = null;
function getIndex(): Map<string, Port> {
if (locodeIndex === null) {
const index = new Map<string, Port>();
for (const port of allPorts) {
index.set(port.locode, port);
}
locodeIndex = index;
}
return locodeIndex;
}
/**
* Find a port by its UN/LOCODE (case-insensitive). Returns `undefined`
* when the locode is not present in the curated registry.
*/
export function findPort(locode: string): Port | undefined {
recordPortLookup("findPort");
return getIndex().get(locode.toUpperCase());
}
/**
* Strict variant of `findPort`. Throws `PortsPortNotFoundError`
* (`PORTS.PORT_NOT_FOUND`) when no port matches.
*/
export function findPortStrict(locode: string): Port {
const port = findPort(locode);
if (port === undefined) {
throw new PortsPortNotFoundError({ locode });
}
return port;
}
/** Find all ports in a given country (ISO 3166-1 alpha-2). */
export function findPortsByCountry(
countryCode: string,
ports: readonly Port[] = allPorts
): Port[] {
recordPortLookup("byCountry");
const upper = countryCode.toUpperCase();
return ports.filter((p) => p.country === upper);
}
/** Find all ports of a given type. */
export function findPortsByType(
type: PortType,
ports: readonly Port[] = allPorts
): Port[] {
recordPortLookup("byType");
return ports.filter((p) => p.type === type);
}
/** Search ports by name (case-insensitive substring match). */
export function searchPorts(
query: string,
ports: readonly Port[] = allPorts
): Port[] {
recordPortLookup("search");
const lower = query.toLowerCase();
return ports.filter((p) => p.name.toLowerCase().includes(lower));
}

View File

@@ -0,0 +1,7 @@
export {
findPort,
findPortStrict,
findPortsByCountry,
findPortsByType,
searchPorts,
} from "./find-port.js";

View File

@@ -0,0 +1,108 @@
// Constants
export {
DEFAULT_SPEED_KNOTS,
EARTH_RADIUS_NM,
EARTH_SPHERE,
VESSEL_CLASS_SPEEDS,
} from "./constants/index.js";
// Curated data
export { airports } from "./data/airports/airports.js";
export { allPorts } from "./data/all-ports/all-ports.js";
export { railTerminals } from "./data/rail-terminals/rail-terminals.js";
export {
seaPorts,
seaPortsAfricaMena,
seaPortsAmericas,
seaPortsAsia,
seaPortsEurope,
seaPortsOceania,
} from "./data/sea-ports/sea-ports.js";
export { shippingLanes } from "./data/shipping-lanes/shipping-lanes.js";
// Distance helpers
export {
distanceKm,
distanceMetresRaw,
distanceNm,
} from "./distance/distance.js";
// Error codes + classes
export {
PORTS_CODES,
PortsError,
type PortsErrorCode,
PortsInsufficientWaypointsError,
PortsInvalidLaneError,
PortsInvalidRadiusError,
PortsInvalidSpeedError,
PortsLaneNotFoundError,
PortsMissingSpeedError,
PortsNoPortsError,
PortsPortNotFoundError,
} from "./error-codes/error-codes.js";
// ETA
export {
type EstimateETAOptions,
estimateETA,
} from "./eta/eta.js";
// Port lookups
export {
findPort,
findPortStrict,
findPortsByCountry,
findPortsByType,
searchPorts,
} from "./find-port/find-port.js";
// Lane distance
export {
cumulativeDistances,
estimateLaneDistance,
} from "./lane-distance/lane-distance.js";
// Lane lookups
export {
getAllShippingLanes,
getShippingLane,
getShippingLaneStrict,
getShippingLanesByMode,
} from "./lane-lookup/lane-lookup.js";
// Lane progress
export { findPositionOnLane } from "./lane-progress/lane-progress.js";
// Nearest port
export {
findNearestPort,
findPortsInRadius,
} from "./nearest-port/nearest-port.js";
// Observability
export {
recordEta,
recordEtaDuration,
recordFindNearestDuration,
recordLaneDistanceDuration,
recordLaneLookup,
recordPortLookup,
refreshPortsMeters,
} from "./otel/otel.js";
// Zod schemas
export {
ETAEstimateSchema,
LaneModeSchema,
LaneProgressSchema,
NearestPortResultSchema,
PortFacilitySchema,
PortSchema,
PortTypeSchema,
ShippingLaneSchema,
SphericalCoordSchema,
} from "./schemas/schemas.js";
// Spatial index
export { PortSpatialIndex } from "./spatial-index/spatial-index.js";
// Types
export type {
ETAEstimate,
LaneMode,
LaneProgress,
NearestPortResult,
Port,
PortFacility,
PortType,
ShippingLane,
SphericalCoord,
} from "./types/port.types.js";

View File

@@ -0,0 +1 @@
export { cumulativeDistances, estimateLaneDistance } from "./lane-distance.js";

View File

@@ -0,0 +1,65 @@
# lane-distance
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Computes great-circle distance metrics for a `ShippingLane`'s waypoint
chain. `estimateLaneDistance` sums every segment for the lane total;
`cumulativeDistances` returns the per-waypoint cumulative array used by
`findPositionOnLane` and `estimateETA`. Both functions reject lanes
with fewer than two waypoints because any distance metric over a
single-point chain is undefined.
## Features
- `estimateLaneDistance(lane)` — total nautical miles, rounded to two
decimals.
- `cumulativeDistances(lane)` — `number[]` of length `waypoints.length`
where entry 0 is 0 and entry i is the cumulative nm distance from
`waypoints[0]` to `waypoints[i]`.
- Throws `PortsInsufficientWaypointsError`
(`PORTS.INSUFFICIENT_WAYPOINTS`) for lanes shorter than two
waypoints.
- Emits the `ports.lane_distance_duration_ms` histogram.
- Uses the package's shared `distanceNm` helper so the rounding policy
is centralised.
## Usage: Basic
```ts
import { estimateLaneDistance, getShippingLaneStrict } from "@suaveplan/ports";
const suez = getShippingLaneStrict("asia-europe-suez");
const totalNm = estimateLaneDistance(suez);
```
## Usage: Advanced
```ts
import { cumulativeDistances, getShippingLaneStrict } from "@suaveplan/ports";
const lane = getShippingLaneStrict("asia-europe-suez");
const cum = cumulativeDistances(lane);
// cum[i] - cum[i-1] gives segment length between waypoints i-1 and i.
for (let i = 1; i < cum.length; i++) {
console.log("segment", i, cum[i] - cum[i - 1], "nm");
}
```
## API Reference
- `estimateLaneDistance(lane: ShippingLane): number`.
- `cumulativeDistances(lane: ShippingLane): number[]`.
## Implementation Notes
Each segment runs through `distanceNm` which itself routes through
`@suaveplan/geo`'s `greatCircleDistance` against the
`EARTH_SPHERE` constant. The cumulative function recomputes segment
distances rather than reusing the running sum from
`estimateLaneDistance` — this keeps the two functions independent so
callers can use either in isolation without paying for both. The
trivial perf cost is acceptable at curated-dataset scale.

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { shippingLanes } from "../data/shipping-lanes/shipping-lanes.js";
import { PortsInsufficientWaypointsError } from "../error-codes/error-codes.js";
import { cumulativeDistances, estimateLaneDistance } from "./lane-distance.js";
describe("lane distance", () => {
it("estimateLaneDistance is positive for curated lanes", {
timeout: 5000,
}, () => {
for (const lane of shippingLanes) {
const d = estimateLaneDistance(lane);
expect(d).toBeGreaterThan(0);
}
});
it("throws on insufficient waypoints", { timeout: 5000 }, () => {
const lane = shippingLanes[0];
if (!lane) throw new Error("expected fixture lane");
const bad = { ...lane, waypoints: [lane.waypoints[0]!] };
expect(() => estimateLaneDistance(bad)).toThrow(
PortsInsufficientWaypointsError
);
});
it("cumulativeDistances starts at zero", { timeout: 5000 }, () => {
const lane = shippingLanes[0];
if (!lane) throw new Error("expected fixture lane");
const cum = cumulativeDistances(lane);
expect(cum[0]).toBe(0);
expect(cum.length).toBe(lane.waypoints.length);
});
it("cumulative distances are monotonically non-decreasing", {
timeout: 5000,
}, () => {
for (const lane of shippingLanes) {
const cum = cumulativeDistances(lane);
for (let i = 1; i < cum.length; i++) {
expect(cum[i]!).toBeGreaterThanOrEqual(cum[i - 1]!);
}
}
});
it("cumulativeDistances throws on insufficient waypoints", {
timeout: 5000,
}, () => {
const lane = shippingLanes[0];
if (!lane) throw new Error("expected fixture lane");
const bad = { ...lane, waypoints: [lane.waypoints[0]!] };
expect(() => cumulativeDistances(bad)).toThrow(
PortsInsufficientWaypointsError
);
});
it("total cumulative distance approximately equals estimated lane distance", {
timeout: 5000,
}, () => {
for (const lane of shippingLanes) {
const total = estimateLaneDistance(lane);
const cum = cumulativeDistances(lane);
const last = cum[cum.length - 1]!;
// Both numbers derive from the same raw segment sums but are rounded differently.
expect(Math.abs(last - total)).toBeLessThan(1);
}
});
});

View File

@@ -0,0 +1,54 @@
import { distanceNm } from "../distance/distance.js";
import { PortsInsufficientWaypointsError } from "../error-codes/error-codes.js";
import { recordLaneDistanceDuration } from "../otel/otel.js";
import type { ShippingLane, SphericalCoord } from "../types/port.types.js";
function rawSegmentNm(a: SphericalCoord, b: SphericalCoord): number {
return distanceNm(a, b);
}
/**
* Estimate the total distance of a shipping lane by summing
* waypoint-to-waypoint great-circle segments. Returns nautical miles,
* rounded to two decimals. Throws `PortsInsufficientWaypointsError`
* (`PORTS.INSUFFICIENT_WAYPOINTS`) when the lane has fewer than two
* waypoints.
*/
export function estimateLaneDistance(lane: ShippingLane): number {
const { waypoints } = lane;
if (waypoints.length < 2) {
throw new PortsInsufficientWaypointsError();
}
const t0 = performance.now();
let totalNm = 0;
for (let i = 0; i < waypoints.length - 1; i++) {
totalNm += rawSegmentNm(
waypoints[i] as SphericalCoord,
waypoints[i + 1] as SphericalCoord
);
}
recordLaneDistanceDuration(performance.now() - t0);
return Math.round(totalNm * 100) / 100;
}
/**
* Cumulative distance (nautical miles) at each waypoint along the lane.
* Entry `i` is the great-circle distance from `waypoints[0]` to
* `waypoints[i]`; entry `0` is always `0`.
*/
export function cumulativeDistances(lane: ShippingLane): number[] {
const { waypoints } = lane;
if (waypoints.length < 2) {
throw new PortsInsufficientWaypointsError();
}
const distances: number[] = [0];
let total = 0;
for (let i = 0; i < waypoints.length - 1; i++) {
total += rawSegmentNm(
waypoints[i] as SphericalCoord,
waypoints[i + 1] as SphericalCoord
);
distances.push(total);
}
return distances;
}

View File

@@ -0,0 +1,6 @@
export {
getAllShippingLanes,
getShippingLane,
getShippingLaneStrict,
getShippingLanesByMode,
} from "./lane-lookup.js";

View File

@@ -0,0 +1,67 @@
# lane-lookup
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public lookup surface for the curated `shippingLanes` registry. Mirrors
the port-lookup module in style: an O(1) `Map` index by lane id, mode
filtering, and a "get all" accessor for downstream consumers that want
to render lists or build their own indices. The strict variant of
`getShippingLane` throws a typed `PortsLaneNotFoundError` so consumers
can branch on `.code` rather than nullable returns.
## Features
- `getShippingLane(id)` — lazy `Map<string, ShippingLane>` lookup.
- `getShippingLaneStrict(id)` — throws on miss with `laneId` in error
context.
- `getShippingLanesByMode(mode, lanes?)` — `LaneMode` filter.
- `getAllShippingLanes()` — returns the curated registry directly
(readonly).
- Every call emits `ports.lane_lookup_total{mode}` (mode is `all` for
`getAllShippingLanes` and miss-paths).
## Usage: Basic
```ts
import { getShippingLane, getShippingLanesByMode } from "@suaveplan/ports";
const suez = getShippingLane("asia-europe-suez");
const seaLanes = getShippingLanesByMode("sea");
```
## Usage: Advanced
```ts
import {
getShippingLaneStrict,
PortsLaneNotFoundError,
} from "@suaveplan/ports";
try {
const lane = getShippingLaneStrict("missing-lane");
} catch (err) {
if (err instanceof PortsLaneNotFoundError) {
// err.context.laneId === "missing-lane"
}
}
```
## API Reference
- `getShippingLane(id: string): ShippingLane | undefined`.
- `getShippingLaneStrict(id: string): ShippingLane`.
- `getShippingLanesByMode(mode: LaneMode, lanes?: readonly ShippingLane[]):
ShippingLane[]`.
- `getAllShippingLanes(): readonly ShippingLane[]`.
## Implementation Notes
The lane id index is built lazily on first call and cached at module
scope for the lifetime of the process. `getAllShippingLanes` returns
the curated registry directly rather than a copy — callers must not
mutate it. Filtering helpers return fresh arrays so callers retain
mutation safety on derived sets.

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { shippingLanes } from "../data/shipping-lanes/shipping-lanes.js";
import { PortsLaneNotFoundError } from "../error-codes/error-codes.js";
import {
getAllShippingLanes,
getShippingLane,
getShippingLaneStrict,
getShippingLanesByMode,
} from "./lane-lookup.js";
describe("lane lookups", () => {
it("getShippingLane returns curated lane by id", { timeout: 5000 }, () => {
const lane = getShippingLane("asia-europe-suez");
expect(lane?.id).toBe("asia-europe-suez");
expect(lane?.mode).toBe("sea");
});
it("getShippingLane returns undefined on miss", { timeout: 5000 }, () => {
expect(getShippingLane("missing-lane")).toBeUndefined();
});
it("getShippingLaneStrict throws on miss", { timeout: 5000 }, () => {
expect(() => getShippingLaneStrict("missing-lane")).toThrow(
PortsLaneNotFoundError
);
});
it("getShippingLaneStrict returns the lane on hit", {
timeout: 5000,
}, () => {
const lane = getShippingLaneStrict("asia-europe-suez");
expect(lane.id).toBe("asia-europe-suez");
});
it("filters by sea mode", { timeout: 5000 }, () => {
const lanes = getShippingLanesByMode("sea");
for (const lane of lanes) expect(lane.mode).toBe("sea");
expect(lanes.length).toBeGreaterThan(0);
});
it("filters by rail mode", { timeout: 5000 }, () => {
const lanes = getShippingLanesByMode("rail");
for (const lane of lanes) expect(lane.mode).toBe("rail");
});
it("filters by air mode", { timeout: 5000 }, () => {
const lanes = getShippingLanesByMode("air");
for (const lane of lanes) expect(lane.mode).toBe("air");
});
it("getAllShippingLanes returns the curated registry", {
timeout: 5000,
}, () => {
expect(getAllShippingLanes()).toBe(shippingLanes);
});
it("custom lanes argument respected", { timeout: 5000 }, () => {
const empty = getShippingLanesByMode("sea", []);
expect(empty).toEqual([]);
});
});

View File

@@ -0,0 +1,44 @@
import { shippingLanes } from "../data/shipping-lanes/shipping-lanes.js";
import { PortsLaneNotFoundError } from "../error-codes/error-codes.js";
import { recordLaneLookup } from "../otel/otel.js";
import type { LaneMode, ShippingLane } from "../types/port.types.js";
let laneIndex: Map<string, ShippingLane> | null = null;
function getIndex(): Map<string, ShippingLane> {
if (laneIndex === null) {
const map = new Map<string, ShippingLane>();
for (const lane of shippingLanes) map.set(lane.id, lane);
laneIndex = map;
}
return laneIndex;
}
/** Get a shipping lane by its ID, or `undefined` when no match exists. */
export function getShippingLane(id: string): ShippingLane | undefined {
const lane = getIndex().get(id);
recordLaneLookup(lane?.mode ?? "all");
return lane;
}
/** Strict variant; throws `PortsLaneNotFoundError` (`PORTS.LANE_NOT_FOUND`) on miss. */
export function getShippingLaneStrict(id: string): ShippingLane {
const lane = getShippingLane(id);
if (lane === undefined) throw new PortsLaneNotFoundError({ laneId: id });
return lane;
}
/** Filter shipping lanes by transport mode. */
export function getShippingLanesByMode(
mode: LaneMode,
lanes: readonly ShippingLane[] = shippingLanes
): ShippingLane[] {
recordLaneLookup(mode);
return lanes.filter((l) => l.mode === mode);
}
/** Get every shipping lane in the curated registry. */
export function getAllShippingLanes(): readonly ShippingLane[] {
recordLaneLookup("all");
return shippingLanes;
}

View File

@@ -0,0 +1 @@
export { findPositionOnLane } from "./lane-progress.js";

View File

@@ -0,0 +1,71 @@
# lane-progress
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Projects a vessel position onto a shipping lane and reports how much of
the lane has been traversed, how much remains, and which waypoint is
nearest. Used by `estimateETA` to derive the remaining-distance figure
that feeds the ETA computation, and exposed publicly so downstream UIs
can render lane-progress bars without re-implementing the geometry.
## Features
- Finds the nearest waypoint by great-circle distance.
- Estimates fractional progress within the nearest waypoint's adjacent
segment (prev or next, whichever is closer).
- Returns `{ progress, remainingDistanceNm, distanceTravelledNm,
nearestWaypointIndex }` with `progress` clamped to `[0, 1]` and
rounded to four decimals; distances clamped to `>= 0` and rounded to
two decimals.
- Throws `PortsInsufficientWaypointsError` for lanes with fewer than
two waypoints.
- Handles degenerate zero-length lanes (every waypoint identical) by
returning zero progress.
## Usage: Basic
```ts
import { findPositionOnLane, getShippingLaneStrict } from "@suaveplan/ports";
const lane = getShippingLaneStrict("asia-europe-suez");
const progress = findPositionOnLane(
{ latitude: 12.5, longitude: 43.3 },
lane
);
```
## Usage: Advanced
```ts
import { findPositionOnLane, type ShippingLane } from "@suaveplan/ports";
function eta(
coord: { latitude: number; longitude: number },
lane: ShippingLane,
knots: number,
departure: Date
): Date {
const { remainingDistanceNm } = findPositionOnLane(coord, lane);
const hours = remainingDistanceNm / knots;
return new Date(departure.getTime() + hours * 3_600_000);
}
```
## API Reference
- `findPositionOnLane(coord, lane): LaneProgress`.
## Implementation Notes
The "nearest waypoint" pass is a linear scan. The adjacent-segment
projection uses a 1D ratio (distance-to-nearest / segment-length)
clamped to `[0, 1]`; this is an approximation that works well when the
coord is reasonably close to the lane and degrades gracefully when the
coord is far off-lane (the ETA `confidence` band downgrades to
`medium`/`low`). The function never throws on out-of-lane coords —
callers receive a clamped progress fraction and a non-negative
remaining distance regardless of how far off-lane the vessel is.

View File

@@ -0,0 +1,189 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { shippingLanes } from "../data/shipping-lanes/shipping-lanes.js";
import { PortsInsufficientWaypointsError } from "../error-codes/error-codes.js";
import type { ShippingLane } from "../types/port.types.js";
import { findPositionOnLane } from "./lane-progress.js";
const SUEZ_LANE = shippingLanes.find(
(l) => l.id === "asia-europe-suez"
) as ShippingLane;
describe("findPositionOnLane", () => {
it("starting position yields zero progress", { timeout: 5000 }, () => {
const result = findPositionOnLane(SUEZ_LANE.waypoints[0]!, SUEZ_LANE);
expect(result.nearestWaypointIndex).toBe(0);
expect(result.distanceTravelledNm).toBe(0);
expect(result.progress).toBe(0);
});
it("end position yields full progress", { timeout: 5000 }, () => {
const last = SUEZ_LANE.waypoints[SUEZ_LANE.waypoints.length - 1]!;
const result = findPositionOnLane(last, SUEZ_LANE);
expect(result.nearestWaypointIndex).toBe(
SUEZ_LANE.waypoints.length - 1
);
expect(result.remainingDistanceNm).toBe(0);
expect(result.progress).toBe(1);
});
it("midpoint waypoint is between 0 and 1", { timeout: 5000 }, () => {
const midIdx = Math.floor(SUEZ_LANE.waypoints.length / 2);
const result = findPositionOnLane(
SUEZ_LANE.waypoints[midIdx]!,
SUEZ_LANE
);
expect(result.progress).toBeGreaterThan(0);
expect(result.progress).toBeLessThan(1);
});
it("throws on insufficient waypoints", { timeout: 5000 }, () => {
const bad = { ...SUEZ_LANE, waypoints: [SUEZ_LANE.waypoints[0]!] };
expect(() => findPositionOnLane(SUEZ_LANE.waypoints[0]!, bad)).toThrow(
PortsInsufficientWaypointsError
);
});
it("off-lane coord between waypoints produces consistent progress", {
timeout: 5000,
}, () => {
// Coordinate offset slightly from a middle waypoint to exercise the
// perpendicular-segment branches.
const midIdx = 5;
const wp = SUEZ_LANE.waypoints[midIdx]!;
const off = {
latitude: wp.latitude + 0.2,
longitude: wp.longitude + 0.2,
};
const result = findPositionOnLane(off, SUEZ_LANE);
expect(result.progress).toBeGreaterThan(0);
expect(result.progress).toBeLessThan(1);
expect(result.distanceTravelledNm).toBeGreaterThan(0);
});
it("returns clamped values on degenerate single-segment lane", {
timeout: 5000,
}, () => {
const tiny: ShippingLane = {
id: "tiny",
name: "tiny",
mode: "sea",
waypoints: [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 0.001 },
],
typicalSpeedKnots: 1,
};
const result = findPositionOnLane({ latitude: 0, longitude: 0 }, tiny);
expect(result.progress).toBeGreaterThanOrEqual(0);
expect(result.progress).toBeLessThanOrEqual(1);
});
it("zero-length lane returns zero progress (totalDistance branch)", {
timeout: 5000,
}, () => {
const degenerate: ShippingLane = {
id: "zero",
name: "zero",
mode: "sea",
waypoints: [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 0 },
],
typicalSpeedKnots: 1,
};
const result = findPositionOnLane(
{ latitude: 0, longitude: 0 },
degenerate
);
expect(result.progress).toBe(0);
expect(result.remainingDistanceNm).toBe(0);
});
it("nearest middle waypoint with closer-to-next branch", {
timeout: 5000,
}, () => {
// Construct a lane where the off-coord is strictly closer to next-wp
// so that branch is exercised.
const lane: ShippingLane = {
id: "abc",
name: "abc",
mode: "sea",
waypoints: [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 10 },
{ latitude: 0, longitude: 20 },
],
typicalSpeedKnots: 10,
};
const result = findPositionOnLane(
{ latitude: 0, longitude: 10.5 },
lane
);
expect(result.nearestWaypointIndex).toBe(1);
expect(result.distanceTravelledNm).toBeGreaterThan(0);
});
it("zero-length next-segment with closer-to-next branch skips projection", {
timeout: 5000,
}, () => {
// Middle waypoint coincides with next; coord at that point → nearestIdx=1,
// distToNext < distToPrev (segment-len 0), `else if (segmentLen>0)` false.
const lane: ShippingLane = {
id: "deg-next",
name: "deg-next",
mode: "sea",
waypoints: [
{ latitude: 0, longitude: -10 },
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 0 },
],
typicalSpeedKnots: 10,
};
const result = findPositionOnLane({ latitude: 0, longitude: 0 }, lane);
expect(result.nearestWaypointIndex).toBe(1);
expect(result.progress).toBe(1);
});
it("zero-length adjacent segment skips projection", {
timeout: 5000,
}, () => {
// 3 waypoints, middle one coincides with prev — closerToPrev branch
// has segmentLen === 0 → both `if` and `else if` skip.
const lane: ShippingLane = {
id: "deg",
name: "deg",
mode: "sea",
waypoints: [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 0 },
],
typicalSpeedKnots: 10,
};
const result = findPositionOnLane({ latitude: 0, longitude: 0 }, lane);
expect(result.distanceTravelledNm).toBe(0);
expect(result.remainingDistanceNm).toBe(0);
});
it("nearest middle waypoint with closer-to-prev branch", {
timeout: 5000,
}, () => {
const lane: ShippingLane = {
id: "abc",
name: "abc",
mode: "sea",
waypoints: [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 10 },
{ latitude: 0, longitude: 20 },
],
typicalSpeedKnots: 10,
};
const result = findPositionOnLane(
{ latitude: 0, longitude: 9.5 },
lane
);
expect(result.nearestWaypointIndex).toBe(1);
expect(result.distanceTravelledNm).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,78 @@
import type { SphericalCoord } from "@suaveplan/geo";
import { distanceNm } from "../distance/distance.js";
import { PortsInsufficientWaypointsError } from "../error-codes/error-codes.js";
import { cumulativeDistances } from "../lane-distance/lane-distance.js";
import type { LaneProgress, ShippingLane } from "../types/port.types.js";
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
/**
* Project a position onto a shipping lane and compute progress. Finds
* the nearest waypoint, then estimates the position within its adjacent
* segment by ratio of perpendicular distance to segment length. Result
* is clamped to `[0, 1]` for `progress` and `>= 0` for distances.
*/
export function findPositionOnLane(
coord: SphericalCoord,
lane: ShippingLane
): LaneProgress {
const { waypoints } = lane;
if (waypoints.length < 2) {
throw new PortsInsufficientWaypointsError();
}
let nearestIdx = 0;
let minDist = distanceNm(coord, waypoints[0] as SphericalCoord);
for (let i = 1; i < waypoints.length; i++) {
const d = distanceNm(coord, waypoints[i] as SphericalCoord);
if (d < minDist) {
minDist = d;
nearestIdx = i;
}
}
const cumDist = cumulativeDistances(lane);
const totalDistance = cumDist[cumDist.length - 1] as number;
let distanceTravelled = cumDist[nearestIdx] as number;
if (nearestIdx > 0 && nearestIdx < waypoints.length - 1) {
const prevPoint = waypoints[nearestIdx - 1] as SphericalCoord;
const here = waypoints[nearestIdx] as SphericalCoord;
const nextPoint = waypoints[nearestIdx + 1] as SphericalCoord;
const distToPrev = distanceNm(coord, prevPoint);
const distToNext = distanceNm(coord, nextPoint);
const closerToPrev = distToPrev < distToNext;
const segmentLen = closerToPrev
? distanceNm(prevPoint, here)
: distanceNm(here, nextPoint);
if (closerToPrev && segmentLen > 0) {
const ratio = 1 - minDist / segmentLen;
distanceTravelled =
(cumDist[nearestIdx - 1] as number) +
((cumDist[nearestIdx] as number) -
(cumDist[nearestIdx - 1] as number)) *
clamp01(ratio);
} else if (segmentLen > 0) {
const ratio = minDist / segmentLen;
distanceTravelled =
(cumDist[nearestIdx] as number) +
((cumDist[nearestIdx + 1] as number) -
(cumDist[nearestIdx] as number)) *
clamp01(ratio);
}
}
const remaining = Math.max(0, totalDistance - distanceTravelled);
const progress = totalDistance > 0 ? distanceTravelled / totalDistance : 0;
return {
progress: Math.round(clamp01(progress) * 10000) / 10000,
remainingDistanceNm: Math.round(remaining * 100) / 100,
distanceTravelledNm:
Math.round(Math.max(0, distanceTravelled) * 100) / 100,
nearestWaypointIndex: nearestIdx,
};
}

View File

@@ -0,0 +1 @@
export { findNearestPort, findPortsInRadius } from "./nearest-port.js";

View File

@@ -0,0 +1,65 @@
# nearest-port
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public proximity-search API. `findNearestPort` returns the single
closest port to a coordinate; `findPortsInRadius` returns every port
within a great-circle radius, sorted ascending by distance. When called
against the default `allPorts` registry the work delegates to a
pre-built `PortSpatialIndex`; explicit overrides fall through to a
linear scan so the caller retains full control over the candidate set.
## Features
- O(neighbours) on the default registry via `PortSpatialIndex`.
- Linear scan fallback for custom port sets.
- `findNearestPort` throws `PortsNoPortsError` (`PORTS.NO_PORTS`) on an
empty input.
- `findPortsInRadius` throws `PortsInvalidRadiusError`
(`PORTS.INVALID_RADIUS`) on `radiusKm <= 0`.
- Distance returned in kilometres, rounded to two decimals.
- Emits `ports.lookup_total{kind}` per call and the
`ports.find_nearest_duration_ms` histogram for `findNearestPort`.
## Usage: Basic
```ts
import { findNearestPort, findPortsInRadius } from "@suaveplan/ports";
const nearest = findNearestPort({ latitude: 1.26, longitude: 103.82 });
const within500 = findPortsInRadius(
{ latitude: 1.26, longitude: 103.82 },
500
);
```
## Usage: Advanced
```ts
import { findNearestPort, findPortsByType } from "@suaveplan/ports";
const onlySea = findPortsByType("sea");
const nearestSea = findNearestPort(
{ latitude: 51.9, longitude: 4.49 },
onlySea
);
```
## API Reference
- `findNearestPort(coord, ports?)` — `NearestPortResult`.
- `findPortsInRadius(coord, radiusKm, ports?)` — `NearestPortResult[]`,
sorted ascending by `distanceKm`.
## Implementation Notes
The spatial index is built lazily on first call against the default
registry. The duration histogram uses `performance.now()` when
available, falling back to `Date.now()` so the package stays usable in
runtimes without a high-resolution timer. Custom port sets bypass the
index because building a fresh index per call would dominate the
linear-scan cost we are trying to avoid.

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { seaPortsAsia } from "../data/sea-ports-asia/sea-ports-asia.js";
import {
PortsInvalidRadiusError,
PortsNoPortsError,
} from "../error-codes/error-codes.js";
import { findNearestPort, findPortsInRadius } from "./nearest-port.js";
describe("findNearestPort", () => {
it("nearest port to Singapore Strait is SGSIN", { timeout: 5000 }, () => {
const result = findNearestPort({ latitude: 1.26, longitude: 103.82 });
expect(result.port.locode).toBe("SGSIN");
expect(result.distanceKm).toBeLessThan(50);
});
it("throws on empty registry", { timeout: 5000 }, () => {
expect(() =>
findNearestPort({ latitude: 0, longitude: 0 }, [])
).toThrow(PortsNoPortsError);
});
it("uses default spatial index when called with allPorts", {
timeout: 5000,
}, () => {
// Hits the index path.
const result = findNearestPort({ latitude: 22.32, longitude: 114.17 });
expect(result.port.locode).toBe("HKHKG");
});
it("uses linear scan when given a custom port set", {
timeout: 5000,
}, () => {
const result = findNearestPort(
{ latitude: 35.4, longitude: 139.6 },
seaPortsAsia
);
expect(result.port.locode).toBe("JPYOK");
});
it("returns rounded distance to two decimals", { timeout: 5000 }, () => {
const result = findNearestPort(
{ latitude: 35.4437, longitude: 139.638 },
seaPortsAsia
);
expect(Number.isFinite(result.distanceKm)).toBe(true);
// Rounded to 2 dp.
expect(result.distanceKm * 100).toBe(
Math.round(result.distanceKm * 100)
);
});
});
describe("findPortsInRadius", () => {
it("throws on zero radius", { timeout: 5000 }, () => {
expect(() =>
findPortsInRadius({ latitude: 0, longitude: 0 }, 0)
).toThrow(PortsInvalidRadiusError);
});
it("throws on negative radius", { timeout: 5000 }, () => {
expect(() =>
findPortsInRadius({ latitude: 0, longitude: 0 }, -10)
).toThrow(PortsInvalidRadiusError);
});
it("returns ports sorted by distance", { timeout: 5000 }, () => {
const results = findPortsInRadius(
{ latitude: 1.26, longitude: 103.82 },
2000
);
for (let i = 1; i < results.length; i++) {
expect(
(results[i - 1] as { distanceKm: number }).distanceKm
).toBeLessThanOrEqual(
(results[i] as { distanceKm: number }).distanceKm
);
}
});
it("custom port set bypasses the spatial index", { timeout: 5000 }, () => {
const results = findPortsInRadius(
{ latitude: 35.4, longitude: 139.6 },
1000,
seaPortsAsia
);
for (const r of results) expect(r.distanceKm).toBeLessThanOrEqual(1000);
});
});

View File

@@ -0,0 +1,76 @@
import type { SphericalCoord } from "@suaveplan/geo";
import { allPorts } from "../data/all-ports/all-ports.js";
import { distanceKm } from "../distance/distance.js";
import {
PortsInvalidRadiusError,
PortsNoPortsError,
} from "../error-codes/error-codes.js";
import { recordFindNearestDuration, recordPortLookup } from "../otel/otel.js";
import { PortSpatialIndex } from "../spatial-index/spatial-index.js";
import type { NearestPortResult, Port } from "../types/port.types.js";
let defaultIndex: PortSpatialIndex | null = null;
function getDefaultIndex(): PortSpatialIndex {
if (defaultIndex === null) defaultIndex = new PortSpatialIndex(allPorts);
return defaultIndex;
}
/**
* Find the nearest port to a coordinate by great-circle distance. When
* the default `allPorts` registry is used the lookup goes through a
* pre-built `PortSpatialIndex`; explicit overrides fall back to a full
* linear scan so callers retain full control over the candidate set.
*/
export function findNearestPort(
coord: SphericalCoord,
ports: readonly Port[] = allPorts
): NearestPortResult {
recordPortLookup("findNearest");
const t0 = performance.now();
if (ports === allPorts) {
// `allPorts` is the curated non-empty registry, so `findNearest`
// always resolves a result. Casting via the index abstraction is
// safe because the index was built from `allPorts.length > 0`.
const result = getDefaultIndex().findNearest(
coord
) as NearestPortResult;
recordFindNearestDuration(performance.now() - t0);
return result;
}
if (ports.length === 0) throw new PortsNoPortsError();
let best: NearestPortResult = {
port: ports[0] as Port,
distanceKm: distanceKm(coord, (ports[0] as Port).coordinate),
};
for (let i = 1; i < ports.length; i++) {
const port = ports[i] as Port;
const d = distanceKm(coord, port.coordinate);
if (d < best.distanceKm) best = { port, distanceKm: d };
}
recordFindNearestDuration(performance.now() - t0);
return best;
}
/**
* Find every port within `radiusKm` of a coordinate, sorted ascending by
* distance. Throws `PortsInvalidRadiusError` when `radiusKm <= 0`.
*/
export function findPortsInRadius(
coord: SphericalCoord,
radiusKm: number,
ports: readonly Port[] = allPorts
): NearestPortResult[] {
if (radiusKm <= 0) throw new PortsInvalidRadiusError();
recordPortLookup("inRadius");
if (ports === allPorts) {
return getDefaultIndex().findInRadius(coord, radiusKm);
}
const results: NearestPortResult[] = [];
for (const port of ports) {
const d = distanceKm(coord, port.coordinate);
if (d <= radiusKm) results.push({ port, distanceKm: d });
}
return results.sort((a, b) => a.distanceKm - b.distanceKm);
}

View File

@@ -0,0 +1,9 @@
export {
recordEta,
recordEtaDuration,
recordFindNearestDuration,
recordLaneDistanceDuration,
recordLaneLookup,
recordPortLookup,
refreshPortsMeters,
} from "./otel.js";

View File

@@ -0,0 +1,72 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wires `@suaveplan/ports`' OpenTelemetry instrumentation: three counters
that track lookup volume per kind, lane lookup volume per mode, and ETA
estimations per confidence band; plus three histograms recording the
duration of `findNearestPort`, `estimateLaneDistance`, and `estimateETA`.
Every public function in the package emits exactly one counter
increment and (where relevant) one histogram sample on each call so
operators can build dashboards without re-instrumenting consumers.
## Features
- Three counters: `ports.lookup_total{kind}`,
`ports.lane_lookup_total{mode}`, `ports.eta_total{confidence}`.
- Three histograms: `ports.find_nearest_duration_ms`,
`ports.lane_distance_duration_ms`, `ports.eta_duration_ms`, all in
millisecond units.
- `refreshPortsMeters()` rebinds every instrument against the current
global `MeterProvider` — tests call this after wiring an in-memory
exporter so subsequent record calls land on the test provider rather
than the module-load meter.
- Recorder helpers (`recordPortLookup`, `recordLaneLookup`,
`recordEta`, `record*Duration`) are 1:1 with the named instruments
and accept the literal-typed label values directly.
- Meter is named `@suaveplan/ports` at version `0.1.0`.
## Usage: Basic
```ts
import { recordPortLookup } from "@suaveplan/ports";
recordPortLookup("findPort");
```
## Usage: Advanced
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import { refreshPortsMeters } from "@suaveplan/ports";
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
refreshPortsMeters();
// Now every subsequent ports operation lands on `provider`.
```
## API Reference
- `recordPortLookup(kind)` — kind ∈ `findPort | byCountry | byType |
search | findNearest | inRadius`.
- `recordLaneLookup(mode)` — mode ∈ `sea | rail | air | all`.
- `recordEta(confidence)` — confidence ∈ `high | medium | low`.
- `recordFindNearestDuration(ms)`,
`recordLaneDistanceDuration(ms)`, `recordEtaDuration(ms)`.
- `refreshPortsMeters()` — re-resolve every instrument.
## Implementation Notes
Instruments are module-scope `let` bindings so `refreshPortsMeters` can
swap them in place; otherwise the OTel API would route emissions to the
original meter even after `setGlobalMeterProvider`. The histograms use
the `ms` unit hint so exporters render them as durations. The package
deliberately does not start its own spans — span management is the
caller's responsibility, and instruments stay focused on aggregate
behaviour rather than per-call tracing.

View File

@@ -0,0 +1,94 @@
import { metrics } from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
recordEta,
recordEtaDuration,
recordFindNearestDuration,
recordLaneDistanceDuration,
recordLaneLookup,
recordPortLookup,
refreshPortsMeters,
} from "./otel.js";
describe("OTel instrumentation surface", () => {
it("counter and histogram recorders run without throwing", {
timeout: 5000,
}, () => {
recordPortLookup("findPort");
recordPortLookup("byCountry");
recordPortLookup("byType");
recordPortLookup("search");
recordPortLookup("findNearest");
recordPortLookup("inRadius");
recordLaneLookup("sea");
recordLaneLookup("rail");
recordLaneLookup("air");
recordLaneLookup("all");
recordEta("high");
recordEta("medium");
recordEta("low");
recordFindNearestDuration(1.5);
recordLaneDistanceDuration(0.5);
recordEtaDuration(2.5);
expect(true).toBe(true);
});
it("refreshPortsMeters rebinds after provider change", {
timeout: 5000,
}, () => {
let createCalls = 0;
const stubMeter = {
createCounter() {
createCalls++;
return { add(): void {} };
},
createHistogram() {
createCalls++;
return { record(): void {} };
},
createUpDownCounter() {
return { add(): void {} };
},
createObservableCounter() {
return {};
},
createObservableGauge() {
return {};
},
createObservableUpDownCounter() {
return {};
},
addBatchObservableCallback(): void {},
removeBatchObservableCallback(): void {},
createGauge() {
return { record(): void {} };
},
};
const stubProvider = {
getMeter() {
return stubMeter;
},
};
// biome safety: cast through unknown — we are not extending the real type.
const original = metrics.getMeterProvider();
metrics.setGlobalMeterProvider(
stubProvider as unknown as ReturnType<
typeof metrics.getMeterProvider
>
);
try {
refreshPortsMeters();
// 3 counters + 3 histograms = 6 instrument creations.
expect(createCalls).toBeGreaterThanOrEqual(6);
recordPortLookup("findPort");
recordLaneLookup("all");
recordEta("high");
recordFindNearestDuration(1);
recordLaneDistanceDuration(1);
recordEtaDuration(1);
} finally {
metrics.setGlobalMeterProvider(original);
refreshPortsMeters();
}
});
});

View File

@@ -0,0 +1,110 @@
import type { Counter, Histogram, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/ports";
const METER_VERSION = "0.1.0";
let meter: Meter = metrics.getMeter(METER_NAME, METER_VERSION);
let lookupCounter: Counter = meter.createCounter("ports.lookup_total", {
description:
"Count of port lookups by kind (findPort/byCountry/byType/search).",
});
let laneLookupCounter: Counter = meter.createCounter(
"ports.lane_lookup_total",
{ description: "Count of shipping-lane lookups by mode." }
);
let etaCounter: Counter = meter.createCounter("ports.eta_total", {
description: "Count of ETA estimations by resulting confidence band.",
});
let findNearestHistogram: Histogram = meter.createHistogram(
"ports.find_nearest_duration_ms",
{ description: "Duration of findNearestPort in milliseconds.", unit: "ms" }
);
let laneDistanceHistogram: Histogram = meter.createHistogram(
"ports.lane_distance_duration_ms",
{
description: "Duration of estimateLaneDistance in milliseconds.",
unit: "ms",
}
);
let etaHistogram: Histogram = meter.createHistogram("ports.eta_duration_ms", {
description: "Duration of estimateETA in milliseconds.",
unit: "ms",
});
/**
* Re-resolve every instrument against the current global
* `MeterProvider`. Tests call this after wiring an in-memory exporter so
* subsequent record calls land on the test provider rather than the
* module-load meter.
*/
export function refreshPortsMeters(): void {
meter = metrics.getMeter(METER_NAME, METER_VERSION);
lookupCounter = meter.createCounter("ports.lookup_total", {
description:
"Count of port lookups by kind (findPort/byCountry/byType/search).",
});
laneLookupCounter = meter.createCounter("ports.lane_lookup_total", {
description: "Count of shipping-lane lookups by mode.",
});
etaCounter = meter.createCounter("ports.eta_total", {
description: "Count of ETA estimations by resulting confidence band.",
});
findNearestHistogram = meter.createHistogram(
"ports.find_nearest_duration_ms",
{
description: "Duration of findNearestPort in milliseconds.",
unit: "ms",
}
);
laneDistanceHistogram = meter.createHistogram(
"ports.lane_distance_duration_ms",
{
description: "Duration of estimateLaneDistance in milliseconds.",
unit: "ms",
}
);
etaHistogram = meter.createHistogram("ports.eta_duration_ms", {
description: "Duration of estimateETA in milliseconds.",
unit: "ms",
});
}
/** Record a port lookup by kind. */
export function recordPortLookup(
kind:
| "findPort"
| "byCountry"
| "byType"
| "search"
| "findNearest"
| "inRadius"
): void {
lookupCounter.add(1, { kind });
}
/** Record a shipping-lane lookup by mode (or "all" for `getAllShippingLanes`). */
export function recordLaneLookup(mode: "sea" | "rail" | "air" | "all"): void {
laneLookupCounter.add(1, { mode });
}
/** Record an ETA estimation by confidence band. */
export function recordEta(confidence: "high" | "medium" | "low"): void {
etaCounter.add(1, { confidence });
}
/** Record a `findNearestPort` duration sample (ms). */
export function recordFindNearestDuration(durationMs: number): void {
findNearestHistogram.record(durationMs);
}
/** Record a `estimateLaneDistance` duration sample (ms). */
export function recordLaneDistanceDuration(durationMs: number): void {
laneDistanceHistogram.record(durationMs);
}
/** Record an `estimateETA` duration sample (ms). */
export function recordEtaDuration(durationMs: number): void {
etaHistogram.record(durationMs);
}

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { shippingLanes } from "../data/shipping-lanes/shipping-lanes.js";
import { distanceKm } from "../distance/distance.js";
import { estimateETA } from "../eta/eta.js";
import { findPortsInRadius } from "../nearest-port/nearest-port.js";
import type { ShippingLane } from "../types/port.types.js";
const SUEZ = shippingLanes.find((l) => l.id === "asia-europe-suez")!;
const speedArb = fc.double({
min: 1,
max: 50,
noNaN: true,
noDefaultInfinity: true,
});
const radiusArb = fc.double({
min: 1,
max: 5000,
noNaN: true,
noDefaultInfinity: true,
});
const latArb = fc.double({
min: -89,
max: 89,
noNaN: true,
noDefaultInfinity: true,
});
const lngArb = fc.double({
min: -179,
max: 179,
noNaN: true,
noDefaultInfinity: true,
});
describe("property: ETA monotonicity", () => {
it("faster speed yields earlier ETA", { timeout: 30000 }, () => {
fc.assert(
fc.property(speedArb, speedArb, (s1, s2) => {
const fast = Math.max(s1, s2) + 0.5;
const slow = Math.min(s1, s2);
if (slow <= 0) return true;
const fastEta = estimateETA(SUEZ.waypoints[0]!, SUEZ, fast, {
departureTime: new Date(0),
});
const slowEta = estimateETA(SUEZ.waypoints[0]!, SUEZ, slow, {
departureTime: new Date(0),
});
return fastEta.etaDate.getTime() <= slowEta.etaDate.getTime();
}),
{ numRuns: 25 }
);
});
});
describe("property: radius containment", () => {
it("every returned port is within radiusKm of the query", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
latArb,
lngArb,
radiusArb,
(latitude, longitude, radiusKm) => {
const results = findPortsInRadius(
{ latitude, longitude },
radiusKm
);
for (const r of results) {
if (r.distanceKm > radiusKm + 1e-6) return false;
}
return true;
}
),
{ numRuns: 15 }
);
});
});
describe("property: distance symmetry + non-negativity", () => {
it("distanceKm is symmetric and non-negative", { timeout: 30000 }, () => {
fc.assert(
fc.property(
latArb,
lngArb,
latArb,
lngArb,
(la1, lo1, la2, lo2) => {
const a = { latitude: la1, longitude: lo1 };
const b = { latitude: la2, longitude: lo2 };
const d1 = distanceKm(a, b);
const d2 = distanceKm(b, a);
return d1 >= 0 && Math.abs(d1 - d2) < 1e-6;
}
),
{ numRuns: 30 }
);
});
});
describe("property: triangle inequality on great-circle", () => {
it("d(a,c) <= d(a,b) + d(b,c) within rounding tolerance", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
latArb,
lngArb,
latArb,
lngArb,
latArb,
lngArb,
(la1, lo1, la2, lo2, la3, lo3) => {
const a = { latitude: la1, longitude: lo1 };
const b = { latitude: la2, longitude: lo2 };
const c = { latitude: la3, longitude: lo3 };
const dab = distanceKm(a, b);
const dbc = distanceKm(b, c);
const dac = distanceKm(a, c);
return dac <= dab + dbc + 1; // 1 km rounding slack
}
),
{ numRuns: 20 }
);
});
});
describe("property: lane mode invariant", () => {
it("every curated lane has a recognised mode", { timeout: 5000 }, () => {
const modes = new Set<ShippingLane["mode"]>(["sea", "rail", "air"]);
for (const lane of shippingLanes)
expect(modes.has(lane.mode)).toBe(true);
});
});

View File

@@ -0,0 +1,11 @@
export {
ETAEstimateSchema,
LaneModeSchema,
LaneProgressSchema,
NearestPortResultSchema,
PortFacilitySchema,
PortSchema,
PortTypeSchema,
ShippingLaneSchema,
SphericalCoordSchema,
} from "./schemas.js";

View File

@@ -0,0 +1,74 @@
# schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines Zod schemas for every public type emitted by `@suaveplan/ports`.
API plugins, message-queue handlers, and any other consumer that
receives a `Port` or `ShippingLane` over the wire passes the payload
through `PortSchema.parse(...)` / `ShippingLaneSchema.parse(...)` to
guarantee shape before the curated functions touch the data. The
schemas mirror the TypeScript interfaces in `types/port.types.ts`
exactly so a runtime success implies a sound compile-time `Port` or
`ShippingLane`.
## Features
- Every TS interface (`Port`, `ShippingLane`, `LaneProgress`,
`ETAEstimate`, `NearestPortResult`, `SphericalCoord`) has a matching
Zod schema.
- Locode is regex-validated as five uppercase A–Z characters.
- Country is regex-validated as ISO 3166-1 alpha-2 (two uppercase).
- IATA code (when present) is regex-validated as three uppercase A–Z.
- Latitude clamped to `[-90, 90]`; longitude to `[-180, 180]`.
- `ShippingLaneSchema` enforces at least two waypoints — matching the
precondition of every lane-distance / lane-progress / ETA function.
- Throughput and capacity fields are non-negative integers.
## Usage: Basic
```ts
import { PortSchema } from "@suaveplan/ports";
const port = PortSchema.parse(payload); // throws on shape mismatch
```
## Usage: Advanced
```ts
import { ShippingLaneSchema, PortsInvalidLaneError } from "@suaveplan/ports";
const result = ShippingLaneSchema.safeParse(payload);
if (!result.success) {
throw new PortsInvalidLaneError({
issues: result.error.issues,
});
}
const lane = result.data;
```
## API Reference
- `SphericalCoordSchema` — `{ latitude, longitude, altitude? }`.
- `PortSchema` — full `Port` shape.
- `PortTypeSchema` — `z.enum(["sea","air","rail","multimodal"])`.
- `PortFacilitySchema` — 10-value enum.
- `ShippingLaneSchema` — `id, name, mode, waypoints≥2, typical*?`.
- `LaneModeSchema` — `z.enum(["sea","rail","air"])`.
- `LaneProgressSchema` — clamped-progress object.
- `ETAEstimateSchema` — `{ remainingDistanceNm, etaHours, etaDate,
confidence }`.
- `NearestPortResultSchema` — `{ port, distanceKm }`.
## Implementation Notes
Schemas use Zod's default-import form (`import z from "zod"`) as
mandated by project rules. Optional fields are declared with
`.optional()` so undefined and absence are interchangeable on the wire.
The schemas do not enforce cross-field invariants (e.g. that
`distanceTravelledNm + remainingDistanceNm <= totalLaneDistance`) —
those invariants are guaranteed by the producing functions, not the
inbound parse step.

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { seaPortsAsia } from "../data/sea-ports-asia/sea-ports-asia.js";
import { shippingLanes } from "../data/shipping-lanes/shipping-lanes.js";
import {
ETAEstimateSchema,
LaneModeSchema,
LaneProgressSchema,
NearestPortResultSchema,
PortFacilitySchema,
PortSchema,
PortTypeSchema,
ShippingLaneSchema,
SphericalCoordSchema,
} from "./schemas.js";
describe("Zod schemas", () => {
it("SphericalCoordSchema accepts valid lat/lng pairs", {
timeout: 5000,
}, () => {
expect(
SphericalCoordSchema.safeParse({ latitude: 0, longitude: 0 })
.success
).toBe(true);
expect(
SphericalCoordSchema.safeParse({
latitude: 51,
longitude: 4,
altitude: 10,
}).success
).toBe(true);
});
it("SphericalCoordSchema rejects out-of-range lat/lng", {
timeout: 5000,
}, () => {
expect(
SphericalCoordSchema.safeParse({ latitude: 91, longitude: 0 })
.success
).toBe(false);
expect(
SphericalCoordSchema.safeParse({ latitude: 0, longitude: 200 })
.success
).toBe(false);
});
it("PortSchema accepts a curated record", { timeout: 5000 }, () => {
const result = PortSchema.safeParse(seaPortsAsia[0]);
expect(result.success).toBe(true);
});
it("PortSchema rejects malformed locode", { timeout: 5000 }, () => {
const bad = { ...seaPortsAsia[0], locode: "abc" };
expect(PortSchema.safeParse(bad).success).toBe(false);
});
it("ShippingLaneSchema accepts curated lane", { timeout: 5000 }, () => {
expect(ShippingLaneSchema.safeParse(shippingLanes[0]).success).toBe(
true
);
});
it("ShippingLaneSchema rejects lanes with fewer than two waypoints", {
timeout: 5000,
}, () => {
const lane = shippingLanes[0];
if (!lane) throw new Error("expected fixture lane");
const bad = { ...lane, waypoints: [lane.waypoints[0]] };
expect(ShippingLaneSchema.safeParse(bad).success).toBe(false);
});
it("PortTypeSchema rejects unknown port types", { timeout: 5000 }, () => {
expect(PortTypeSchema.safeParse("submarine").success).toBe(false);
expect(PortTypeSchema.safeParse("sea").success).toBe(true);
});
it("LaneModeSchema accepts the three lane modes", { timeout: 5000 }, () => {
expect(LaneModeSchema.safeParse("sea").success).toBe(true);
expect(LaneModeSchema.safeParse("rail").success).toBe(true);
expect(LaneModeSchema.safeParse("air").success).toBe(true);
});
it("PortFacilitySchema accepts known facility tags", {
timeout: 5000,
}, () => {
expect(PortFacilitySchema.safeParse("container").success).toBe(true);
expect(PortFacilitySchema.safeParse("unknown").success).toBe(false);
});
it("LaneProgressSchema validates progress shape", { timeout: 5000 }, () => {
expect(
LaneProgressSchema.safeParse({
progress: 0.5,
remainingDistanceNm: 100,
distanceTravelledNm: 100,
nearestWaypointIndex: 0,
}).success
).toBe(true);
});
it("ETAEstimateSchema validates ETA shape", { timeout: 5000 }, () => {
expect(
ETAEstimateSchema.safeParse({
remainingDistanceNm: 100,
etaHours: 7.14,
etaDate: new Date(),
confidence: "high",
}).success
).toBe(true);
});
it("NearestPortResultSchema validates result shape", {
timeout: 5000,
}, () => {
expect(
NearestPortResultSchema.safeParse({
port: seaPortsAsia[0],
distanceKm: 12.34,
}).success
).toBe(true);
});
});

View File

@@ -0,0 +1,86 @@
import z from "zod";
/**
* Universal coordinate schema. Mirrors `@suaveplan/geo`'s
* `SphericalCoord`. Latitude is clamped to [-90, 90] and longitude to
* [-180, 180]; altitude is optional metres above the ellipsoid.
*/
export const SphericalCoordSchema = z.object({
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
altitude: z.number().optional(),
});
/** Schema for `PortType`. */
export const PortTypeSchema = z.enum(["sea", "air", "rail", "multimodal"]);
/** Schema for `PortFacility`. */
export const PortFacilitySchema = z.enum([
"container",
"bulk",
"roro",
"tanker",
"passenger",
"cargo",
"reefer",
"intermodal",
"bonded_warehouse",
"free_trade_zone",
]);
/** Schema for `LaneMode`. */
export const LaneModeSchema = z.enum(["sea", "rail", "air"]);
/** Schema for a `Port`. */
export const PortSchema = z.object({
locode: z.string().regex(/^[A-Z]{5}$/u),
name: z.string().min(1),
country: z.string().regex(/^[A-Z]{2}$/u),
coordinate: SphericalCoordSchema,
type: PortTypeSchema,
timezone: z.string().min(1),
facilities: z.array(PortFacilitySchema).readonly(),
maxVesselSizeTEU: z.number().int().positive().optional(),
maxVesselSizeDWT: z.number().int().positive().optional(),
runwayLengthM: z.number().int().positive().optional(),
annualThroughputTEU: z.number().int().nonnegative().optional(),
annualThroughputTonnes: z.number().int().nonnegative().optional(),
iataCode: z
.string()
.regex(/^[A-Z]{3}$/u)
.optional(),
uicCode: z.string().min(1).optional(),
});
/** Schema for a `ShippingLane`. */
export const ShippingLaneSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
mode: LaneModeSchema,
waypoints: z.array(SphericalCoordSchema).min(2),
typicalTransitDays: z.number().positive().optional(),
typicalSpeedKnots: z.number().positive().optional(),
typicalSpeedKmh: z.number().positive().optional(),
});
/** Schema for `LaneProgress`. */
export const LaneProgressSchema = z.object({
progress: z.number().min(0).max(1),
remainingDistanceNm: z.number().nonnegative(),
distanceTravelledNm: z.number().nonnegative(),
nearestWaypointIndex: z.number().int().nonnegative(),
});
/** Schema for `ETAEstimate`. */
export const ETAEstimateSchema = z.object({
remainingDistanceNm: z.number().nonnegative(),
etaHours: z.number().nonnegative(),
etaDate: z.date(),
confidence: z.enum(["high", "medium", "low"]),
});
/** Schema for `NearestPortResult`. */
export const NearestPortResultSchema = z.object({
port: PortSchema,
distanceKm: z.number().nonnegative(),
});

View File

@@ -0,0 +1 @@
export { PortSpatialIndex } from "./spatial-index.js";

View File

@@ -0,0 +1,76 @@
# spatial-index
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PortSpatialIndex` buckets a port set by 5-degree latitude/longitude
cells at construction time so `findNearest` and `findInRadius` can run
against a small candidate set instead of the full registry. The 3×3
cell neighbourhood is ~600 km wide, so a 500-km radius query (the
common case) visits at most nine cells. For the curated ~120-port
dataset the absolute saving is small, but the API stays O(neighbours)
as the dataset grows.
## Features
- 5° lat/lng cell bucketing — bucket key is
`floor(lat / 5) + ":" + floor(lng / 5)`.
- O(neighbours) `findNearest` that falls back to a full scan when the
3×3 neighbourhood is empty (Pacific gyre, polar regions).
- `findInRadius(coord, radiusKm)` returns ports within `radiusKm`
sorted ascending by distance, switching to a full scan when the
requested radius exceeds the half-width of the 3×3 neighbourhood
(~830 km) to guarantee correctness.
- Immutable after construction — no add/remove API, so callers rebuild
to reflect updates and the index never falls out of sync with its
source set.
- `size` exposes the total indexed port count.
## Usage: Basic
```ts
import { PortSpatialIndex, allPorts } from "@suaveplan/ports";
const index = new PortSpatialIndex(allPorts);
const nearest = index.findNearest({ latitude: 1.26, longitude: 103.82 });
```
## Usage: Advanced
```ts
import { PortSpatialIndex } from "@suaveplan/ports";
// Tenant-scoped index built once per request.
function buildIndex(allowed: Port[]): PortSpatialIndex {
return new PortSpatialIndex(allowed);
}
const tenant = buildIndex(allowedPortsForTenant);
const nearby = tenant.findInRadius(
{ latitude: 35.4, longitude: 139.6 },
250
);
```
## API Reference
- `new PortSpatialIndex(ports)` — pre-bucket the input.
- `index.size` — total indexed port count.
- `index.neighbourhood(coord)` — every port in the 3×3 cell around
`coord`.
- `index.findNearest(coord)` — nearest `NearestPortResult` or
`undefined` when the index is empty.
- `index.findInRadius(coord, radiusKm)` — sorted `NearestPortResult[]`.
## Implementation Notes
The 5° cell width was chosen because individual seas (the Med, the
Caribbean, the Sea of Japan) span 2–4 cells, which keeps the
neighbourhood candidate set tight without artificially excluding ports
on cell boundaries. The radius branch threshold (`> 1.5 × 5° × 111`)
ensures we never miss a port outside the neighbourhood: a circle of
radius greater than half the neighbourhood width must be tested
against the full set.

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { allPorts } from "../data/all-ports/all-ports.js";
import { seaPortsAsia } from "../data/sea-ports-asia/sea-ports-asia.js";
import { PortSpatialIndex } from "./spatial-index.js";
describe("PortSpatialIndex", () => {
it("indexes every input port", { timeout: 5000 }, () => {
const idx = new PortSpatialIndex(allPorts);
expect(idx.size).toBe(allPorts.length);
});
it("findNearest on empty registry returns undefined", {
timeout: 5000,
}, () => {
const idx = new PortSpatialIndex([]);
expect(idx.findNearest({ latitude: 0, longitude: 0 })).toBeUndefined();
});
it("findNearest on populated index returns nearest port", {
timeout: 5000,
}, () => {
const idx = new PortSpatialIndex(seaPortsAsia);
const result = idx.findNearest({ latitude: 1.26, longitude: 103.82 });
expect(result?.port.locode).toBe("SGSIN");
expect(result?.distanceKm).toBeLessThan(20);
});
it("findNearest falls back to full scan in sparse regions", {
timeout: 5000,
}, () => {
// South Pacific gyre — far from every curated port.
const idx = new PortSpatialIndex(seaPortsAsia);
const result = idx.findNearest({ latitude: -60, longitude: -150 });
expect(result).toBeDefined();
expect(result?.distanceKm).toBeGreaterThan(5000);
});
it("neighbourhood returns only nearby ports", { timeout: 5000 }, () => {
const idx = new PortSpatialIndex(seaPortsAsia);
const ports = idx.neighbourhood({ latitude: 31, longitude: 121 });
// Should include Shanghai and Ningbo (both in CN, near 31N 121E).
expect(ports.some((p) => p.locode === "CNSHA")).toBe(true);
});
it("findInRadius small radius constrains to neighbours", {
timeout: 5000,
}, () => {
const idx = new PortSpatialIndex(seaPortsAsia);
const results = idx.findInRadius(
{ latitude: 1.26, longitude: 103.82 },
500
);
for (const r of results) expect(r.distanceKm).toBeLessThanOrEqual(500);
expect(results.some((r) => r.port.locode === "SGSIN")).toBe(true);
});
it("findInRadius large radius switches to full scan", {
timeout: 5000,
}, () => {
const idx = new PortSpatialIndex(seaPortsAsia);
const results = idx.findInRadius({ latitude: 0, longitude: 0 }, 25_000);
// 25 000 km covers half the planet; expect every Asia port.
expect(results.length).toBe(seaPortsAsia.length);
});
it("findInRadius is sorted ascending by distance", {
timeout: 5000,
}, () => {
const idx = new PortSpatialIndex(seaPortsAsia);
const results = idx.findInRadius(
{ latitude: 1.26, longitude: 103.82 },
2000
);
for (let i = 1; i < results.length; i++) {
expect(
(results[i - 1] as { distanceKm: number }).distanceKm
).toBeLessThanOrEqual(
(results[i] as { distanceKm: number }).distanceKm
);
}
});
});

View File

@@ -0,0 +1,106 @@
import type { SphericalCoord } from "@suaveplan/geo";
import { distanceKm } from "../distance/distance.js";
import type { NearestPortResult, Port } from "../types/port.types.js";
const CELL_DEGREES = 5;
function cellKey(latitude: number, longitude: number): string {
return `${Math.floor(latitude / CELL_DEGREES)}:${Math.floor(longitude / CELL_DEGREES)}`;
}
/**
* Bucket-grid spatial index keyed by 5° lat/lng cells. Queries fetch the
* 3×3 cell neighbourhood and run exact great-circle distance against the
* candidate set. For ~120 curated ports the absolute saving is small but
* the API stays O(neighbours) as the dataset grows, and the result
* shape is identical to a full scan.
*/
export class PortSpatialIndex {
private readonly cells: Map<string, Port[]>;
private readonly ports: readonly Port[];
public constructor(ports: readonly Port[]) {
this.ports = ports;
this.cells = new Map();
for (const port of ports) {
const key = cellKey(
port.coordinate.latitude,
port.coordinate.longitude
);
const bucket = this.cells.get(key);
if (bucket === undefined) {
this.cells.set(key, [port]);
} else {
bucket.push(port);
}
}
}
/** Total number of indexed ports. */
public get size(): number {
return this.ports.length;
}
/** Return every port in the 3×3 cell neighbourhood of `coord`. */
public neighbourhood(coord: SphericalCoord): Port[] {
const lat0 = Math.floor(coord.latitude / CELL_DEGREES);
const lon0 = Math.floor(coord.longitude / CELL_DEGREES);
const out: Port[] = [];
for (let di = -1; di <= 1; di++) {
for (let dj = -1; dj <= 1; dj++) {
const key = `${lat0 + di}:${lon0 + dj}`;
const bucket = this.cells.get(key);
if (bucket !== undefined) {
for (const p of bucket) out.push(p);
}
}
}
return out;
}
/**
* Find the nearest port to `coord`. Tries the 3×3 neighbourhood first;
* falls back to a full scan when the neighbourhood is empty (sparse
* regions of the globe).
*/
public findNearest(coord: SphericalCoord): NearestPortResult | undefined {
if (this.ports.length === 0) return undefined;
let candidates = this.neighbourhood(coord);
if (candidates.length === 0) candidates = [...this.ports];
let best: NearestPortResult = {
port: candidates[0] as Port,
distanceKm: distanceKm(coord, (candidates[0] as Port).coordinate),
};
for (let i = 1; i < candidates.length; i++) {
const port = candidates[i] as Port;
const d = distanceKm(coord, port.coordinate);
if (d < best.distanceKm) {
best = { port, distanceKm: d };
}
}
return best;
}
/**
* Return every port within `radiusKm` of `coord`. When the radius
* exceeds the half-width of a 3×3 cell neighbourhood the full set is
* scanned to guarantee correctness.
*/
public findInRadius(
coord: SphericalCoord,
radiusKm: number
): NearestPortResult[] {
const neighbourhoodHalfWidthKm = CELL_DEGREES * 111 * 1.5;
const candidates =
radiusKm > neighbourhoodHalfWidthKm
? this.ports
: this.neighbourhood(coord);
const results: NearestPortResult[] = [];
for (const port of candidates) {
const d = distanceKm(coord, port.coordinate);
if (d <= radiusKm) results.push({ port, distanceKm: d });
}
return results.sort((a, b) => a.distanceKm - b.distanceKm);
}
}

View File

@@ -0,0 +1,80 @@
# port.types
## Purpose
Defines every type emitted by `@suaveplan/ports`: the curated `Port`
record, the `ShippingLane` waypoint graph, the result types for lane
projection (`LaneProgress`) and ETA estimation (`ETAEstimate`), and the
discriminating unions for port type and facility tags. Every public
function in the package consumes or returns one of these types so the
file is the single source of truth for the package's data contract.
## Features
- `Port` carries a UN/LOCODE primary key plus geographic coordinate,
country, type, timezone, and facility tags.
- Optional capacity hints (`maxVesselSizeTEU`, `maxVesselSizeDWT`,
`runwayLengthM`, `annualThroughputTEU`, `annualThroughputTonnes`).
- `PortType` discriminates between `sea | air | rail | multimodal`.
- `ShippingLane.waypoints` is an ordered `SphericalCoord` array.
- `LaneMode` is `sea | rail | air`.
- `ETAEstimate.confidence` is `high | medium | low` derived from
deviation between travelled+remaining and the lane's total distance.
- Re-exports `SphericalCoord` from `@suaveplan/geo` so consumers only
import from a single canonical location.
## Usage: Basic
```ts
import type { Port } from "@suaveplan/ports";
const port: Port = {
locode: "SGSIN",
name: "Singapore",
country: "SG",
coordinate: { latitude: 1.26, longitude: 103.82 },
type: "sea",
timezone: "Asia/Singapore",
facilities: ["container", "bulk"],
};
```
## Usage: Advanced
```ts
import type { ETAEstimate, ShippingLane } from "@suaveplan/ports";
function logEta(eta: ETAEstimate, lane: ShippingLane): void {
console.info({
lane: lane.id,
etaHours: eta.etaHours,
confidence: eta.confidence,
remainingNm: eta.remainingDistanceNm,
});
}
```
## API Reference
- `Port` — curated port record with optional capacity hints.
- `PortType` — `"sea" | "air" | "rail" | "multimodal"`.
- `PortFacility` — 10-value union: container, bulk, roro, tanker,
passenger, cargo, reefer, intermodal, bonded_warehouse,
free_trade_zone.
- `ShippingLane` — id, name, mode, waypoints, optional typical transit
days and speeds (knots or km/h).
- `LaneMode` — `"sea" | "rail" | "air"`.
- `LaneProgress` — `{ progress, remainingDistanceNm,
distanceTravelledNm, nearestWaypointIndex }`.
- `ETAEstimate` — `{ remainingDistanceNm, etaHours, etaDate, confidence }`.
- `NearestPortResult` — `{ port, distanceKm }`.
- `SphericalCoord` — re-exported from `@suaveplan/geo`.
## Implementation Notes
Every interface is `readonly` end-to-end so curated records remain
immutable across consumers. Optional fields are non-nullable
unioned with `undefined` via TypeScript's exact-optional semantics —
schemas in `schemas/schemas.ts` enforce the same shape at runtime for
incoming payloads. The file deliberately contains no runtime code so
type-only imports do not pay any bundle cost.

View File

@@ -0,0 +1,109 @@
import type { SphericalCoord } from "@suaveplan/geo";
/**
* Geographic coordinate used by the ports package. Re-exports the geo
* package's universal `SphericalCoord` so downstream consumers can
* import the type from a single canonical location.
*/
export type { SphericalCoord };
/** Classification of port infrastructure type. */
export type PortType = "sea" | "air" | "rail" | "multimodal";
/** Cargo-handling capability tag attached to a `Port`. */
export type PortFacility =
| "container"
| "bulk"
| "roro"
| "tanker"
| "passenger"
| "cargo"
| "reefer"
| "intermodal"
| "bonded_warehouse"
| "free_trade_zone";
/** A port, airport, or rail terminal with geographic + operational data. */
export interface Port {
/** UN/LOCODE identifier, e.g. "CNSHA". */
readonly locode: string;
/** Human-readable name, e.g. "Shanghai". */
readonly name: string;
/** ISO 3166-1 alpha-2 country code, e.g. "CN". */
readonly country: string;
/** Geographic position. */
readonly coordinate: SphericalCoord;
/** Infrastructure classification. */
readonly type: PortType;
/** IANA timezone identifier, e.g. "Asia/Shanghai". */
readonly timezone: string;
/** Cargo-handling capabilities. */
readonly facilities: readonly PortFacility[];
/** Maximum vessel size in TEU (sea ports). */
readonly maxVesselSizeTEU?: number;
/** Maximum vessel size in DWT (sea ports). */
readonly maxVesselSizeDWT?: number;
/** Runway length in metres (airports). */
readonly runwayLengthM?: number;
/** Estimated annual container throughput in TEU. */
readonly annualThroughputTEU?: number;
/** Estimated annual cargo throughput in tonnes. */
readonly annualThroughputTonnes?: number;
/** IATA airport code, e.g. "PVG". */
readonly iataCode?: string;
/** UIC railway code (rail terminals). */
readonly uicCode?: string;
}
/** Transport mode for a shipping lane. */
export type LaneMode = "sea" | "rail" | "air";
/** A predefined shipping route between origin and destination. */
export interface ShippingLane {
/** Unique identifier, e.g. "asia-europe-suez". */
readonly id: string;
/** Human-readable name. */
readonly name: string;
/** Transport mode. */
readonly mode: LaneMode;
/** Ordered waypoints defining the route path. */
readonly waypoints: readonly SphericalCoord[];
/** Typical transit time in days. */
readonly typicalTransitDays?: number;
/** Typical speed in knots (sea lanes). */
readonly typicalSpeedKnots?: number;
/** Typical speed in km/h (rail/air lanes). */
readonly typicalSpeedKmh?: number;
}
/** Result of projecting a position onto a shipping lane. */
export interface LaneProgress {
/** Fraction of the lane completed (0 to 1, rounded to 4 dp). */
readonly progress: number;
/** Remaining distance in nautical miles. */
readonly remainingDistanceNm: number;
/** Distance already travelled in nautical miles. */
readonly distanceTravelledNm: number;
/** Index of the nearest waypoint on the lane. */
readonly nearestWaypointIndex: number;
}
/** Estimated time of arrival result. */
export interface ETAEstimate {
/** Remaining distance in nautical miles. */
readonly remainingDistanceNm: number;
/** Remaining travel time in hours. */
readonly etaHours: number;
/** Projected arrival date. */
readonly etaDate: Date;
/** Confidence based on position accuracy relative to lane. */
readonly confidence: "high" | "medium" | "low";
}
/** Result of a nearest-port search. */
export interface NearestPortResult {
/** The matched port. */
readonly port: Port;
/** Great-circle distance from the query coordinate in kilometres. */
readonly distanceKm: number;
}

View File

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

View File

@@ -0,0 +1,58 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: {
index: "./src/index.ts",
"constants/index": "./src/constants/index.ts",
"error-codes/error-codes": "./src/error-codes/error-codes.ts",
"schemas/schemas": "./src/schemas/schemas.ts",
"otel/otel": "./src/otel/otel.ts",
"distance/distance": "./src/distance/distance.ts",
"find-port/find-port": "./src/find-port/find-port.ts",
"spatial-index/spatial-index":
"./src/spatial-index/spatial-index.ts",
"nearest-port/nearest-port":
"./src/nearest-port/nearest-port.ts",
"lane-lookup/lane-lookup": "./src/lane-lookup/lane-lookup.ts",
"lane-distance/lane-distance":
"./src/lane-distance/lane-distance.ts",
"lane-progress/lane-progress":
"./src/lane-progress/lane-progress.ts",
"eta/eta": "./src/eta/eta.ts",
"data/sea-ports/sea-ports":
"./src/data/sea-ports/sea-ports.ts",
"data/airports/airports": "./src/data/airports/airports.ts",
"data/rail-terminals/rail-terminals":
"./src/data/rail-terminals/rail-terminals.ts",
"data/all-ports/all-ports":
"./src/data/all-ports/all-ports.ts",
"data/shipping-lanes/shipping-lanes":
"./src/data/shipping-lanes/shipping-lanes.ts",
"data/sea-ports-asia/sea-ports-asia":
"./src/data/sea-ports-asia/sea-ports-asia.ts",
"data/sea-ports-europe/sea-ports-europe":
"./src/data/sea-ports-europe/sea-ports-europe.ts",
"data/sea-ports-americas/sea-ports-americas":
"./src/data/sea-ports-americas/sea-ports-americas.ts",
"data/sea-ports-africa-mena/sea-ports-africa-mena":
"./src/data/sea-ports-africa-mena/sea-ports-africa-mena.ts",
"data/sea-ports-oceania/sea-ports-oceania":
"./src/data/sea-ports-oceania/sea-ports-oceania.ts",
},
formats: ["es"],
},
outDir: "dist",
sourcemap: true,
rollupOptions: {
external: [/^@suaveplan\//, "zod", /^@opentelemetry\//, "tslib"],
output: {
preserveModules: true,
preserveModulesRoot: "src",
},
},
},
plugins: [dts({ tsconfigPath: "./tsconfig.lib.json" })],
});

View File

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