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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@
Eurostat resolution (5/10/20/50/100 km); the 10 km and 5 km networks are too
large to bundle and are documented for use via `loadNetwork`. A size/accuracy
tradeoff table was added to the README. (#11)
- `via` option — require a route to traverse named passages, the inverse of
`restrictions`. `seaRoute(o, d, { via: ['panama'] })` forces a Pacific + Panama
routing; `via` accepts the same `Passage` names as `restrictions` and visits
multiple passages in order (routing `origin → passage → destination` through
each passage's location via the multi-leg machinery). A passage named in `via`
is never blocked out from under the requirement (so `via: ['northeast']` needs
no `allowArctic`); naming a passage in both `via` and `restrictions` throws
`NoRouteError`. (#8)

## 2.2.0 — 2026-07-03

Expand Down
4 changes: 4 additions & 0 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ Weight `0` tells `PathFinder` the edge is non-passable, so Dijkstra never traver
- **Native check:** uses the `pass` attribute on the marnet feature itself. Exact, no false positives. Available for the 12 canals/straits that Eurostat labels.
- **Bbox fallback:** for passages without native labels, we sample 6 points along the edge and check if any falls inside the passage's bounding box. This catches both midpoint-in-bbox cases and long edges that "jump" a narrow channel.

### Forcing passages (`via`)

`via: Passage[]` is the inverse of `restrictions`: instead of blocking a passage, it **requires** one. Rather than manipulating edge weights, it reuses the multi-leg machinery — the route is computed as `origin → passage₁ → … → passageₙ → destination`, snapping each passage's `PASSAGE_BBOXES` centroid to the network as an intermediate waypoint. Legs inherit the caller's effective restrictions, minus any passage named in `via` (so a required passage is never blocked out from under the requirement — e.g. `via: ['northeast']` works without `allowArctic`). A passage that appears in both `via` and `restrictions` (compared after alias canonicalisation) is a contradiction and throws `NoRouteError`. `greatCircleLength`/`detourRatio` are still measured against the direct origin→destination geodesic, matching plain `seaRoute`.

### Arctic gating

The Northwest and Northeast Passages are mathematically the shortest path for many Asia ↔ Europe and Asia ↔ East-Coast-Americas routes (think Yokohama → New York via the Bering Strait + Northwest Passage). They are ice-blocked most of the year and not used by commercial shipping. So they're **blocked by default**. Opt in with `allowArctic: true`.
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,8 @@ GDAL conversion), host the resulting JSON, and load it with
```ts
seaRoute(origin, destination, {
units: 'nauticalmiles', // any Turf unit
restrictions: ['suez', 'babelmandeb'], // see passage table below
restrictions: ['suez', 'babelmandeb'], // block passages (see table below)
via: ['panama'], // require passages (inverse of restrictions)
allowArctic: false, // default — blocks NWP & NEP
vesselDraftMeters: 15, // auto-restrict canals
speedKnots: 22, // → properties.durationHours
Expand Down Expand Up @@ -301,6 +302,25 @@ dateline (ideal for MapLibre/Leaflet/Deck.gl). `'split'` cuts the route into a
`MultiLineString` at ±180°, keeping every coordinate in range. Both apply to
`seaRoute` and `seaRouteMulti`; `properties.length` is unchanged either way.

### Forcing routes through a passage (`via`)

`restrictions` **blocks** a passage; `via` **requires** one — the inverse. Use
it to compare explicit routings, e.g. "via Suez" against "via Cape of Good Hope",
or to force a Pacific + Panama routing between Asia and Europe:

```ts
seaRoute('CNSHA', 'NLRTM', { via: ['suez'] }); // through Suez (the default)
seaRoute('CNSHA', 'NLRTM', { via: ['panama'] }); // across the Pacific + Panama instead
```

`via` accepts the same passage names as `restrictions` and visits multiple
passages in the order given. It routes `origin → passage → destination` through
each passage's location using the multi-leg machinery, so it composes with the
other options. A passage named in `via` is never blocked out from under the
requirement (`via: ['northeast']` reaches the Northeast Passage without also
needing `allowArctic`). Naming the same passage in both `via` and `restrictions`
is a contradiction and throws `NoRouteError`.

## Restrictable passages

The first twelve are **natively labelled** in the Eurostat marnet (exact match
Expand Down
91 changes: 91 additions & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,97 @@ test('allowArctic:true permits NEP for Asia→Europe and shortens the route', (t
t.true((open.properties.passages ?? []).includes('northeast'), 'should use NEP');
});

// ── via (forced passages) ───────────────────────────────────────────────────

test('via: [panama] forces Shanghai→Rotterdam across the Pacific + Panama (not Suez)', (t) => {
const direct = seaRoute(SHANGHAI, ROTTERDAM, { units: 'kilometers', returnPassages: true });
const forced = seaRoute(SHANGHAI, ROTTERDAM, {
units: 'kilometers',
via: ['panama'],
returnPassages: true,
});
const p = new Set(forced.properties.passages ?? []);
t.true(p.has('panama'), `should traverse Panama, got ${[...p]}`);
t.false(p.has('suez'), 'should not use Suez when forced via Panama');
// Pacific + Panama + Atlantic is far longer than the default Suez routing.
t.true(
forced.properties.length > direct.properties.length,
`forced ${forced.properties.length} should exceed direct ${direct.properties.length}`,
);
// Sweeps into the western hemisphere on the trans-Pacific crossing.
t.true(
forced.properties.bbox[0] < -100,
`bbox should reach the Americas: ${forced.properties.bbox}`,
);
});

test('via: [magellan] forces NY→LA around South America instead of Panama', (t) => {
clearFinderCache();
const direct = seaRoute(NYC, LA, { units: 'kilometers', returnPassages: true });
const forced = seaRoute(NYC, LA, {
units: 'kilometers',
via: ['magellan'],
returnPassages: true,
});
t.true((direct.properties.passages ?? []).includes('panama'), 'baseline uses Panama');
const p = new Set(forced.properties.passages ?? []);
t.true(p.has('magellan'), `should traverse Magellan, got ${[...p]}`);
// Rounding Cape Horn is thousands of km longer than the Panama shortcut,
// which proves the route went around South America rather than transiting
// the canal. (The Pacific leg still skirts Panama's Pacific approaches, so
// the bbox-based passage flag can legitimately include 'panama'.)
t.true(
forced.properties.length > direct.properties.length + 10000,
`forced ${forced.properties.length} should be much longer than direct ${direct.properties.length}`,
);
});

test('via: [suez] keeps Shanghai→Rotterdam on the Suez routing', (t) => {
const forced = seaRoute(SHANGHAI, ROTTERDAM, {
units: 'kilometers',
via: ['suez'],
returnPassages: true,
});
t.true((forced.properties.passages ?? []).includes('suez'));
});

test('via: [northeast] routes through the NEP without needing allowArctic', (t) => {
// Arctic passages are blocked by default, but a passage named in `via` must
// not be blocked out from under the requirement.
const forced = seaRoute(SHANGHAI, ROTTERDAM, {
units: 'kilometers',
via: ['northeast'],
returnPassages: true,
});
t.true(
(forced.properties.passages ?? []).includes('northeast'),
`should use the NEP, got ${forced.properties.passages}`,
);
});

test('via keeps the great-circle reference measured origin→destination', (t) => {
const direct = seaRoute(SHANGHAI, ROTTERDAM, { units: 'kilometers' });
const forced = seaRoute(SHANGHAI, ROTTERDAM, { units: 'kilometers', via: ['panama'] });
t.is(
Math.round(forced.properties.greatCircleLength),
Math.round(direct.properties.greatCircleLength),
'great-circle length should reference origin→destination, not the via waypoints',
);
});

test('via and restrictions in contradiction throw NoRouteError', (t) => {
t.throws(() => seaRoute(SHANGHAI, ROTTERDAM, { via: ['suez'], restrictions: ['suez'] }), {
instanceOf: NoRouteError,
});
});

test('via/restrictions contradiction is detected across passage aliases', (t) => {
t.throws(
() => seaRoute(SHANGHAI, ROTTERDAM, { via: ['babalmandab'], restrictions: ['babelmandeb'] }),
{ instanceOf: NoRouteError },
);
});

// ── Output shape ────────────────────────────────────────────────────────────

test('appendOriginDestination adds endpoints but does not change length', (t) => {
Expand Down
87 changes: 86 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ 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 {
canonicalizePassage,
type Passage,
passageCentroid,
passagesAlong,
} from './lib/restrictions.js';
import { snapToNetwork } from './lib/snap.js';

export type { Antimeridian } from './lib/antimeridian.js';
Expand Down Expand Up @@ -50,6 +55,20 @@ export type SeaRouteOptions = {
* `{ restrictions: ['suez', 'babelmandeb'] }`
*/
restrictions?: Passage[];
/**
* Named passages the route is required to traverse — the inverse of
* `restrictions`. Useful to compare explicit routings, e.g. "via Suez"
* against "via Cape of Good Hope":
* `{ via: ['panama'] }`
*
* Implemented by routing `origin → passage → destination` through the
* passage's location (via the multi-leg machinery), so multiple passages are
* visited in the order given. A passage named here is never blocked out from
* under the requirement (so, for example, `via: ['northeast']` reaches the
* Northeast Passage without also needing `allowArctic`). Passing a passage in
* both `via` and `restrictions` is a contradiction and throws `NoRouteError`.
*/
via?: Passage[];
/**
* When `false` (the default), the Northwest and Northeast Passages are
* implicitly added to `restrictions`. They are mathematically the shortest
Expand Down Expand Up @@ -197,6 +216,10 @@ export function seaRoute(
const options: SeaRouteOptions =
typeof unitsOrOptions === 'string' ? { units: unitsOrOptions } : unitsOrOptions;

if (options.via && options.via.length > 0) {
return routeVia(origin, destination, options);
}

const units: Units = options.units ?? 'nauticalmiles';
const network = options.network ?? DEFAULT_MARNET;
const restrictions = resolveRestrictions(options);
Expand Down Expand Up @@ -471,6 +494,68 @@ export async function loadNetwork(

// ── Internal helpers ────────────────────────────────────────────────────────

/**
* Force a route through one or more named passages (the `via` option).
*
* Routes `origin → passage₁ → … → passageₙ → destination` through each
* passage's location, reusing the multi-leg machinery. A passage named in
* `via` is never blocked out from under the requirement; naming the same
* passage in both `via` and `restrictions` is a contradiction.
*
* @throws {NoRouteError} when a `via` passage is also restricted, or when no
* path through the requested passages exists.
*/
function routeVia(
origin: PointInput,
destination: PointInput,
options: SeaRouteOptions,
): SeaRouteFeature | SeaRouteMultiFeature {
const via = options.via ?? [];
const units: Units = options.units ?? 'nauticalmiles';

// A passage cannot be both required and forbidden.
const restrictedCanon = new Set((options.restrictions ?? []).map(canonicalizePassage));
for (const p of via) {
if (restrictedCanon.has(canonicalizePassage(p))) {
throw new NoRouteError(`Cannot route via '${p}' while it is also restricted`);
}
}

// Resolve the full effective restriction set (user + draft + arctic) once,
// then drop any passage we are forcing through so it isn't blocked per leg.
const viaCanon = new Set(via.map(canonicalizePassage));
const legRestrictions = resolveRestrictions(options).filter(
(r) => !viaCanon.has(canonicalizePassage(r)),
);

const waypoints: PointInput[] = [origin, ...via.map(passageCentroid), destination];
const legOptions: SeaRouteOptions = {
...options,
via: undefined,
restrictions: legRestrictions,
// The full effective set is already folded into legRestrictions; disable
// the implicit additions so each leg doesn't re-add arctic/draft blocks.
allowArctic: true,
vesselDraftMeters: undefined,
};

const route =
options.antimeridian === 'split'
? seaRouteMulti(waypoints, { ...legOptions, antimeridian: 'split' })
: seaRouteMulti(waypoints, { ...legOptions, antimeridian: options.antimeridian });

// Report great-circle length / detour against the actual origin→destination
// geodesic (not the forced waypoints), matching seaRoute's semantics.
const o = toFeaturePoint(origin).geometry.coordinates;
const d = toFeaturePoint(destination).geometry.coordinates;
const gcKm = greatCircleKm(o, d);
route.properties.greatCircleLength = convertKm(gcKm, units);
const totalKm = route.properties.length * unitToKm(units);
route.properties.detourRatio = gcKm > 0 ? totalKm / gcKm : 1;

return route;
}

/**
* Build the output feature from route coordinates, applying the antimeridian
* option: `'split'` yields a `MultiLineString` cut at ±180°, `'unwrap'` yields a
Expand Down
19 changes: 19 additions & 0 deletions src/lib/restrictions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,25 @@ export const PASSAGE_BBOXES: Record<Passage, Bbox[]> = {
northeast: [[30.0, 68.0, 180.0, 82.0]],
};

/**
* Centroid `[lon, lat]` of a passage's bbox region, used as a waypoint to force
* a route through the passage (the `via` option). When a passage has several
* bboxes, the mean of their individual centres is returned.
*/
export function passageCentroid(passage: Passage): Position {
const bboxes = PASSAGE_BBOXES[passage];
if (!bboxes || bboxes.length === 0) {
throw new Error(`Unknown passage: ${passage}`);
}
let lon = 0;
let lat = 0;
for (const [minLon, minLat, maxLon, maxLat] of bboxes) {
lon += (minLon + maxLon) / 2;
lat += (minLat + maxLat) / 2;
}
return [lon / bboxes.length, lat / bboxes.length];
}

/**
* Exact segment-vs-bbox test (Liang–Barsky clip). A narrow strait bbox can
* sit entirely between two vertices of a long network edge, so sampling
Expand Down
Loading