Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## Unreleased

### Added
- Port-code (UN/LOCODE) input: `seaRoute`, `seaRouteMulti` and
`seaRouteAlternatives` accept a code string (e.g. `seaRoute('CNSHA', 'NLRTM')`)
anywhere a point is accepted, mixable with coordinates. The ~1 600-port dataset
ships behind the new `searoute-ts/ports` subpath export (source:
[marchah/sea-ports](https://github.com/marchah/sea-ports), MIT, based on
UN/LOCODE), so the core stays lean — importing the subpath registers the
resolver. New `searoute-ts/ports` exports `lookupPort`, `resolvePort`, `PORTS`,
`PORT_COUNT`, `Port`, `PortRecord`; the core adds `UnknownPortError`,
`registerPortResolver` and a `PortResolver` type. Unknown codes throw
`UnknownPortError`. (#7)
- `loadPorts(url, options?)` — optionally fetch the UN/LOCODE port dataset from a
URL/CDN at runtime and register it, enabling `seaRoute('CNSHA', 'NLRTM')`
without bundling the `searoute-ts/ports` dataset. The dataset also ships as a
raw `dist/ports.json`, so it is served versioned by jsDelivr/unpkg
(`https://cdn.jsdelivr.net/npm/searoute-ts@latest/dist/ports.json`). Uses the
global `fetch`; pass `{ fetch }` to override. (#7)

## 2.1.0 — 2026-07-03

### Added
Expand Down
6 changes: 5 additions & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ Two side-effects:
- `properties.originSnapKm` and `properties.destinationSnapKm` report how far the inputs moved when snapped. Useful for debugging routes that look "off" — if `originSnapKm` is 600 km, your input was nowhere near the sea.
- The `maxSnapDistanceKm` option throws `SnapFailedError` if the input is further than the threshold. Without it, a typo'd coordinate in the middle of a continent silently snaps to the nearest coast.

### Port-code input (UN/LOCODE)

An origin or destination can be a UN/LOCODE string (e.g. `'CNSHA'`). Because the port dataset would bloat the core, it ships behind the `searoute-ts/ports` subpath export; importing that module registers a resolver into the core (or supply your own via `registerPortResolver`). A string input is resolved to `[lon, lat]` first, then follows the same snapping path as a coordinate. Codes are matched case-insensitively with whitespace stripped; unknown codes throw `UnknownPortError`. The dataset (~1 600 seaports) comes from [marchah/sea-ports](https://github.com/marchah/sea-ports) (MIT), derived from UN/LOCODE — see `scripts/build-ports.cjs`.

---

## 3. Shortest path
Expand Down Expand Up @@ -175,7 +179,7 @@ The finder cache is keyed by network identity, so swapping networks at runtime w

- **Navigation.** Routes are graph paths, not great-circle or rhumb-line tracks. Don't sail them.
- **Weather-aware.** No wave height, currents, ice forecasts, or seasonal variation. The Northwest/Northeast Passages are gated by `allowArctic`, not by an ice model. For weather routing see [VISIR-2](https://gmd.copernicus.org/articles/17/4355/2024/).
- **Port-aware.** No port database, ETAs, or berth selection. Routes terminate at the nearest network vertex to the input coordinates.
- **Port-aware.** UN/LOCODE strings are accepted as input (via `searoute-ts/ports`) and resolved to coordinates, but there are no ETAs, berth selection, or terminal-level detail. Routes terminate at the nearest network vertex to the (resolved) input coordinates.
- **Emissions-grade.** Duration estimates assume constant speed in calm water. For CO₂ accounting see [searoutes.com](https://searoutes.com/co2-api/) or implement your own model on top of the duration.

---
Expand Down
79 changes: 78 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,56 @@ seaRoute(shanghai, newYork, {
// → Panama auto-blocked, route goes via Suez
```

### Port codes (UN/LOCODE)

```ts
import 'searoute-ts/ports'; // enables UN/LOCODE strings on the core API
import { seaRoute } from 'searoute-ts';

seaRoute('CNSHA', 'NLRTM'); // Shanghai → Rotterdam
seaRoute('CNSHA', [4.4, 51.9]); // mixing a code and coordinates is fine too
```

The ~1 600-port dataset lives behind the `searoute-ts/ports` subpath so the core
stays lean — importing it registers the resolver. You can also resolve codes
yourself:

```ts
import { lookupPort, resolvePort } from 'searoute-ts/ports';

lookupPort('SGSIN'); // → { code, name: 'Singapore', country, coordinates: [lon, lat] }
resolvePort('SGSIN'); // → [103.85, 1.28]
```

Unknown codes throw `UnknownPortError`. See [Port codes](#port-codes-unlocode-1) below for provenance.

#### Load the port dataset from a CDN instead of bundling it

Don't want to bundle the ~135 KB dataset? Fetch it at runtime with `loadPorts` —
the analog of [`loadNetwork`](#fetch-the-network-from-a-url-instead-of-bundling-it-optional).
The dataset also ships as a raw `dist/ports.json`, so **jsDelivr/unpkg serve it
versioned for free**:

```ts
import { seaRoute, loadPorts } from 'searoute-ts';

// Pin a version for reproducibility, or use @latest to always get the newest.
await loadPorts('https://cdn.jsdelivr.net/npm/searoute-ts@latest/dist/ports.json');

seaRoute('CNSHA', 'NLRTM'); // works — the fetched dataset is now registered
```

```
https://cdn.jsdelivr.net/npm/searoute-ts@latest/dist/ports.json # newest
https://cdn.jsdelivr.net/npm/searoute-ts@<version>/dist/ports.json # frozen/immutable
```

(`dist/ports.json` ships from the release that adds port codes onward — pin any
version at or after it for reproducibility.)

`loadPorts` registers the fetched dataset (so code strings resolve) and returns
it. It uses the global `fetch` (Node ≥18 / browsers); pass `{ fetch }` to override.

### Multi-leg / port rotation

```ts
Expand Down Expand Up @@ -198,7 +248,8 @@ seaRoute(origin, destination, {
});
```

Inputs can be `[lon, lat]` arrays, GeoJSON `Feature<Point>`, or bare `Point` objects.
Inputs can be `[lon, lat]` arrays, GeoJSON `Feature<Point>`, bare `Point` objects,
or a UN/LOCODE string (e.g. `'CNSHA'`) once `searoute-ts/ports` is imported.

### Antimeridian (dateline) handling

Expand Down Expand Up @@ -284,6 +335,8 @@ import {
clearFinderCache, // drop the PathFinder cache (tests / hot reload)
SnapFailedError,
NoRouteError,
UnknownPortError, // thrown for unresolved UN/LOCODE strings
registerPortResolver, // plug in a custom port dataset
// types
type Passage,
type Antimeridian,
Expand All @@ -295,8 +348,32 @@ import {
type MarnetNetwork,
type MarnetProperties,
} from 'searoute-ts';

import {
lookupPort, // UN/LOCODE → { code, name, country, coordinates }
resolvePort, // UN/LOCODE → [lon, lat]
PORTS, // the raw dataset (Record<code, PortRecord>)
PORT_COUNT,
type Port,
type PortRecord,
} from 'searoute-ts/ports';
```

## Port codes (UN/LOCODE)

Origins and destinations may be given as UN/LOCODE strings (e.g. `'CNSHA'`)
instead of coordinates. The port dataset ships behind the `searoute-ts/ports`
subpath export, so consumers only pay for it if they use it — importing the
subpath (for any of its exports, or purely for its side effect) registers a
resolver into the core so `seaRoute('CNSHA', 'NLRTM')` works.

- **~1 600 seaports**, keyed by UN/LOCODE (primary codes and aliases).
- **Source:** [marchah/sea-ports](https://github.com/marchah/sea-ports) (MIT),
itself derived from **UN/LOCODE**. Regenerate with `scripts/build-ports.cjs`.
- **Coordinates are approximate** (port-city granularity) — the routing engine
snaps them onto the network anyway, so this is fine for distance/visualisation.
- Unknown or malformed codes throw `UnknownPortError`.

## How it works

A two-page deep-dive (graph data, snapping, Dijkstra, restrictions,
Expand Down
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,16 @@
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./ports": {
"types": "./dist/types/ports/index.d.ts",
"import": "./dist/esm/ports/index.js",
"require": "./dist/cjs/ports/index.js"
},
"./package.json": "./package.json"
},
"sideEffects": false,
"sideEffects": [
"**/ports/index.js"
],
"repository": {
"type": "git",
"url": "git+https://github.com/mayurrawte/searoute-ts.git"
Expand Down Expand Up @@ -53,7 +60,7 @@
],
"scripts": {
"clean": "rm -rf dist build coverage",
"build": "npm run clean && npm run build:cjs && npm run build:esm && npm run build:types && node scripts/fixup-cjs.cjs && node scripts/copy-marnet.cjs dist",
"build": "npm run clean && npm run build:cjs && npm run build:esm && npm run build:types && node scripts/fixup-cjs.cjs && node scripts/copy-marnet.cjs dist && node scripts/emit-ports-json.cjs",
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:esm": "tsc -p tsconfig.esm.json",
"build:types": "tsc -p tsconfig.types.json",
Expand Down
89 changes: 89 additions & 0 deletions scripts/build-ports.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/* Generate src/ports/data.ts from the marchah/sea-ports dataset.
*
* Source: https://github.com/marchah/sea-ports (MIT), a curated set of seaports
* keyed by UN/LOCODE with coordinates, itself derived from UN/LOCODE. We keep
* only { name, country, coordinates } and index every UN/LOCODE alias so both a
* port's primary code and its aliases resolve.
*
* The data is embedded as a JSON string + JSON.parse() so the TypeScript
* compiler doesn't pretty-print a large object literal. Ships behind the
* `searoute-ts/ports` subpath export so the core stays lean.
*
* Refresh with:
* curl -sSL https://raw.githubusercontent.com/marchah/sea-ports/master/lib/ports.json -o /tmp/seaports.json
* node scripts/build-ports.cjs /tmp/seaports.json
*/
const fs = require('fs');
const path = require('path');

const input = process.argv[2] || '/tmp/seaports.json';
const outPath = path.resolve(__dirname, '..', 'src/ports/data.ts');

const source = JSON.parse(fs.readFileSync(input, 'utf8'));

const isValidCode = (code) => /^[A-Z]{2}[A-Z0-9]{3}$/.test(code);
const isValidCoord = (c) =>
Array.isArray(c) &&
c.length === 2 &&
Number.isFinite(c[0]) &&
Number.isFinite(c[1]) &&
Math.abs(c[0]) <= 180 &&
Math.abs(c[1]) <= 90;

const round = (n) => Math.round(n * 1e6) / 1e6;

const ports = {};
let skipped = 0;
for (const [key, entry] of Object.entries(source)) {
const coords = entry.coordinates;
if (!isValidCoord(coords)) {
skipped++;
continue;
}
const record = {
name: entry.name,
country: entry.country || '',
coordinates: [round(coords[0]), round(coords[1])],
};
const codes = new Set([key, ...(Array.isArray(entry.unlocs) ? entry.unlocs : [])]);
for (const raw of codes) {
const code = String(raw).toUpperCase();
if (!isValidCode(code)) continue;
if (!ports[code]) ports[code] = record; // first entry wins on collisions
}
}

// Deterministic key order keeps the generated file stable across runs.
const sorted = {};
for (const code of Object.keys(ports).sort()) sorted[code] = ports[code];

const json = JSON.stringify(sorted);
const literal = JSON.stringify(json); // safe double-quoted JS string literal

const header = `/* Auto-generated by scripts/build-ports.cjs. Do not edit by hand.
*
* Curated seaport dataset keyed by UN/LOCODE, derived from
* https://github.com/marchah/sea-ports (MIT), itself based on UN/LOCODE.
* Only { name, country, coordinates: [lon, lat] } is kept, indexed by every
* UN/LOCODE alias. Embedded as a JSON string parsed once at module load.
*/
export type PortRecord = { name: string; country: string; coordinates: [number, number] };

const PORTS: Record<string, PortRecord> = JSON.parse(${literal}) as Record<string, PortRecord>;

export default PORTS;
`;

fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, header);
console.log(
'wrote',
path.relative(path.resolve(__dirname, '..'), outPath),
'—',
Object.keys(sorted).length,
'codes,',
skipped,
'entries skipped (no coords),',
(fs.statSync(outPath).size / 1024).toFixed(0),
'KB',
);
13 changes: 13 additions & 0 deletions scripts/emit-ports-json.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Emit the UN/LOCODE port dataset as a raw dist/ports.json so it is served
// versioned by the npm CDNs (jsDelivr/unpkg) for use with loadPorts(url).
// Runs after the CJS build, reading the compiled dataset module.
const fs = require('node:fs');
const path = require('node:path');

const dataModule = path.join(__dirname, '..', 'dist', 'cjs', 'ports', 'data.js');
const mod = require(dataModule);
const records = mod.default ?? mod;

const out = path.join(__dirname, '..', 'dist', 'ports.json');
fs.writeFileSync(out, JSON.stringify(records));
console.log(`wrote ${out} (${Object.keys(records).length} port codes)`);
10 changes: 10 additions & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
seaRouteAlternatives,
seaRouteMulti,
SnapFailedError,
UnknownPortError,
} from './index';

function pt(lon: number, lat: number): Feature<Point> {
Expand Down Expand Up @@ -553,6 +554,15 @@ test('seaRouteAlternatives ignores antimeridian and returns LineStrings', (t) =>
});
for (const a of alts) t.is(a.geometry.type, 'LineString');
});
// ── Port codes (core, without the dataset) ──────────────────────────────────

test('a UN/LOCODE string throws UnknownPortError when no dataset is registered', (t) => {
// The core does not bundle the port dataset; without importing
// 'searoute-ts/ports', a string origin/destination cannot be resolved.
const err = t.throws(() => seaRoute('CNSHA', 'NLRTM'), { instanceOf: UnknownPortError });
t.is(err?.code, 'CNSHA');
t.regex(err!.message, /searoute-ts\/ports/);
});

// ── Custom network ──────────────────────────────────────────────────────────

Expand Down
19 changes: 17 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { type Antimeridian, splitAtAntimeridian, unwrapCoords } from './lib/anti
import { passagesBlockedByDraft } from './lib/drafts.js';
import { buildFinder, DEFAULT_MARNET, type MarnetNetwork } from './lib/finder.js';
import { bboxOf, greatCircleKm } from './lib/metrics.js';
import { resolvePortCode } from './lib/ports.js';
import { type Passage, passagesAlong } from './lib/restrictions.js';
import { snapToNetwork } from './lib/snap.js';

Expand All @@ -23,9 +24,22 @@ export type { MarnetNetwork } from './lib/finder.js';
export { DEFAULT_MARNET, clearFinderCache } from './lib/finder.js';
export { SnapFailedError } from './lib/snap.js';
export { CANAL_MAX_DRAFT_M } from './lib/drafts.js';
export {
UnknownPortError,
registerPortResolver,
loadPorts,
type PortResolver,
type PortDataset,
type LoadPortsOptions,
} from './lib/ports.js';

/** Input accepted as origin/destination. */
export type PointInput = Position | Feature<Point> | Point;
/**
* Input accepted as origin/destination. A `string` is treated as a UN/LOCODE
* port code (e.g. `'CNSHA'`); resolving codes requires importing the
* `searoute-ts/ports` subpath (or registering a resolver via
* `registerPortResolver`).
*/
export type PointInput = Position | Feature<Point> | Point | string;

export type SeaRouteOptions = {
/** Output unit for `properties.length`. Defaults to nautical miles. */
Expand Down Expand Up @@ -138,6 +152,7 @@ export class NoRouteError extends Error {
}

function toFeaturePoint(input: PointInput): Feature<Point> {
if (typeof input === 'string') return turfPoint(resolvePortCode(input));
if (Array.isArray(input)) return turfPoint(input);
if ('geometry' in input) return input as Feature<Point>;
return turfFeature(input as Point) as Feature<Point>;
Expand Down
Loading
Loading