Initial Commit
This commit is contained in:
104
packages/geospatial/logistics/ports/README.md
Normal file
104
packages/geospatial/logistics/ports/README.md
Normal 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).
|
||||
|
||||
103
packages/geospatial/logistics/ports/package.json
Normal file
103
packages/geospatial/logistics/ports/package.json
Normal 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"]
|
||||
}
|
||||
43
packages/geospatial/logistics/ports/src/constants/index.ts
Normal file
43
packages/geospatial/logistics/ports/src/constants/index.ts
Normal 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);
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { airports } from "./airports.js";
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export { airports, allPorts, railTerminals, seaPorts } from "./all-ports.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { railTerminals } from "./rail-terminals.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { seaPortsAfricaMena } from "./sea-ports-africa-mena.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { seaPortsAmericas } from "./sea-ports-americas.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { seaPortsAsia } from "./sea-ports-asia.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { seaPortsEurope } from "./sea-ports-europe.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { seaPortsOceania } from "./sea-ports-oceania.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
seaPorts,
|
||||
seaPortsAfricaMena,
|
||||
seaPortsAmericas,
|
||||
seaPortsAsia,
|
||||
seaPortsEurope,
|
||||
seaPortsOceania,
|
||||
} from "./sea-ports.js";
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export { shippingLanes } from "./shipping-lanes.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
69
packages/geospatial/logistics/ports/src/distance/distance.md
Normal file
69
packages/geospatial/logistics/ports/src/distance/distance.md
Normal 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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
31
packages/geospatial/logistics/ports/src/distance/distance.ts
Normal file
31
packages/geospatial/logistics/ports/src/distance/distance.ts
Normal 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");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { distanceKm, distanceMetresRaw, distanceNm } from "./distance.js";
|
||||
@@ -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: "..." }`.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
13
packages/geospatial/logistics/ports/src/error-codes/index.ts
Normal file
13
packages/geospatial/logistics/ports/src/error-codes/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
PORTS_CODES,
|
||||
PortsError,
|
||||
type PortsErrorCode,
|
||||
PortsInsufficientWaypointsError,
|
||||
PortsInvalidLaneError,
|
||||
PortsInvalidRadiusError,
|
||||
PortsInvalidSpeedError,
|
||||
PortsLaneNotFoundError,
|
||||
PortsMissingSpeedError,
|
||||
PortsNoPortsError,
|
||||
PortsPortNotFoundError,
|
||||
} from "./error-codes.js";
|
||||
70
packages/geospatial/logistics/ports/src/eta/eta.md
Normal file
70
packages/geospatial/logistics/ports/src/eta/eta.md
Normal 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.
|
||||
170
packages/geospatial/logistics/ports/src/eta/eta.test.ts
Normal file
170
packages/geospatial/logistics/ports/src/eta/eta.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
86
packages/geospatial/logistics/ports/src/eta/eta.ts
Normal file
86
packages/geospatial/logistics/ports/src/eta/eta.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
1
packages/geospatial/logistics/ports/src/eta/index.ts
Normal file
1
packages/geospatial/logistics/ports/src/eta/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type EstimateETAOptions, estimateETA } from "./eta.js";
|
||||
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
findPort,
|
||||
findPortStrict,
|
||||
findPortsByCountry,
|
||||
findPortsByType,
|
||||
searchPorts,
|
||||
} from "./find-port.js";
|
||||
108
packages/geospatial/logistics/ports/src/index.ts
Normal file
108
packages/geospatial/logistics/ports/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1 @@
|
||||
export { cumulativeDistances, estimateLaneDistance } from "./lane-distance.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getAllShippingLanes,
|
||||
getShippingLane,
|
||||
getShippingLaneStrict,
|
||||
getShippingLanesByMode,
|
||||
} from "./lane-lookup.js";
|
||||
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { findPositionOnLane } from "./lane-progress.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { findNearestPort, findPortsInRadius } from "./nearest-port.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
9
packages/geospatial/logistics/ports/src/otel/index.ts
Normal file
9
packages/geospatial/logistics/ports/src/otel/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
recordEta,
|
||||
recordEtaDuration,
|
||||
recordFindNearestDuration,
|
||||
recordLaneDistanceDuration,
|
||||
recordLaneLookup,
|
||||
recordPortLookup,
|
||||
refreshPortsMeters,
|
||||
} from "./otel.js";
|
||||
72
packages/geospatial/logistics/ports/src/otel/otel.md
Normal file
72
packages/geospatial/logistics/ports/src/otel/otel.md
Normal 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.
|
||||
94
packages/geospatial/logistics/ports/src/otel/otel.test.ts
Normal file
94
packages/geospatial/logistics/ports/src/otel/otel.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
110
packages/geospatial/logistics/ports/src/otel/otel.ts
Normal file
110
packages/geospatial/logistics/ports/src/otel/otel.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
11
packages/geospatial/logistics/ports/src/schemas/index.ts
Normal file
11
packages/geospatial/logistics/ports/src/schemas/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
ETAEstimateSchema,
|
||||
LaneModeSchema,
|
||||
LaneProgressSchema,
|
||||
NearestPortResultSchema,
|
||||
PortFacilitySchema,
|
||||
PortSchema,
|
||||
PortTypeSchema,
|
||||
ShippingLaneSchema,
|
||||
SphericalCoordSchema,
|
||||
} from "./schemas.js";
|
||||
74
packages/geospatial/logistics/ports/src/schemas/schemas.md
Normal file
74
packages/geospatial/logistics/ports/src/schemas/schemas.md
Normal 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.
|
||||
121
packages/geospatial/logistics/ports/src/schemas/schemas.test.ts
Normal file
121
packages/geospatial/logistics/ports/src/schemas/schemas.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
86
packages/geospatial/logistics/ports/src/schemas/schemas.ts
Normal file
86
packages/geospatial/logistics/ports/src/schemas/schemas.ts
Normal 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(),
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { PortSpatialIndex } from "./spatial-index.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
80
packages/geospatial/logistics/ports/src/types/port.types.md
Normal file
80
packages/geospatial/logistics/ports/src/types/port.types.md
Normal 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.
|
||||
109
packages/geospatial/logistics/ports/src/types/port.types.ts
Normal file
109
packages/geospatial/logistics/ports/src/types/port.types.ts
Normal 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;
|
||||
}
|
||||
12
packages/geospatial/logistics/ports/tsconfig.lib.json
Normal file
12
packages/geospatial/logistics/ports/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
58
packages/geospatial/logistics/ports/vite.config.ts
Normal file
58
packages/geospatial/logistics/ports/vite.config.ts
Normal 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" })],
|
||||
});
|
||||
10
packages/geospatial/logistics/ports/vitest.config.ts
Normal file
10
packages/geospatial/logistics/ports/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
coverageThresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user