From cfe18304393d1407da9c311275870ddfe6750a3d Mon Sep 17 00:00:00 2001 From: mayurrawte Date: Sat, 4 Jul 2026 02:20:18 +0000 Subject: [PATCH] feat: add via option to force routes through named passages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `via` option to seaRoute — the inverse of `restrictions`. Instead of blocking a passage it requires one, routing origin → passage → destination through each passage's PASSAGE_BBOXES centroid via the multi-leg machinery. - `via` accepts the same Passage names as `restrictions` and visits multiple passages in order. - A passage named in `via` is excluded from the effective per-leg restriction set, so a required passage is never blocked out from under the requirement (e.g. via: ['northeast'] needs no allowArctic). - Naming a passage in both `via` and `restrictions` (after alias canonicalisation) throws NoRouteError. - greatCircleLength/detourRatio remain measured against the direct origin→destination geodesic. Adds passageCentroid() helper. Tests, README, DOCS and CHANGELOG updated. --- CHANGELOG.md | 8 ++++ DOCS.md | 4 ++ README.md | 22 +++++++++- src/index.spec.ts | 91 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 87 ++++++++++++++++++++++++++++++++++++++- src/lib/restrictions.ts | 19 +++++++++ 6 files changed, 229 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c58a3..7322327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DOCS.md b/DOCS.md index d0f3cc7..7ea481c 100644 --- a/DOCS.md +++ b/DOCS.md @@ -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`. diff --git a/README.md b/README.md index 7055831..35ebaec 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/src/index.spec.ts b/src/index.spec.ts index adff06f..7b11732 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -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) => { diff --git a/src/index.ts b/src/index.ts index 2297471..e8da634 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -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 @@ -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); @@ -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 diff --git a/src/lib/restrictions.ts b/src/lib/restrictions.ts index 9126925..6bd4e58 100644 --- a/src/lib/restrictions.ts +++ b/src/lib/restrictions.ts @@ -86,6 +86,25 @@ export const PASSAGE_BBOXES: Record = { 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