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
21 changes: 19 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## Unreleased

### Added
- Optional higher-resolution networks as **subpath exports**. Import
`DEFAULT_MARNET` from `searoute-ts/marnet-20km` (Eurostat `marnet_plus_20km`,
~29,581 segments) or `searoute-ts/marnet-50km` (~15,498 segments) and pass it
to the `network` option for finer coastal routing than the bundled 100 km
default. Each variant ships once as a shared `dist/data/marnet-<res>.cjs`
asset that both the CJS and ESM builds load at runtime (building on #10), so
importing a variant doesn't bloat the core or duplicate data across builds.
`scripts/build-marnet.cjs` now takes a resolution label and can generate any
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)

## 2.2.0 — 2026-07-03

### Added
Expand Down Expand Up @@ -163,9 +178,11 @@ see ~24% smaller numbers — they now match the geodesic length in nm correctly.
- ~~The marnet is inlined in the bundle (~1.1 MB per build, ~2.2 MB total
unpacked). Migrating to a single shared JSON asset is a non-breaking
follow-up and would roughly halve published size.~~ Done in Unreleased (#10).
- Multi-resolution networks (Eurostat ships 5 km / 10 km / 20 km / 50 km /
- ~~Multi-resolution networks (Eurostat ships 5 km / 10 km / 20 km / 50 km /
100 km). Currently we only ship 100 km. Other resolutions could be loaded
via a separate import path.
via a separate import path.~~ 20 km and 50 km now ship as the
`searoute-ts/marnet-20km` / `searoute-ts/marnet-50km` subpath exports; 10 km
and 5 km are generatable via `scripts/build-marnet.cjs`. Done in Unreleased (#11).

## 1.2.1 — 2022-07-12
First public release of the TypeScript port. See git history for prior 1.x
Expand Down
9 changes: 8 additions & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@ A consequence is that a route which crosses the Pacific comes back wrapped to `[

### Other resolutions

Eurostat ships 5 km / 10 km / 20 km / 50 km / 100 km networks. We bundle only 100 km to keep the package small. For higher resolution, use the `network` option — see [Custom networks](#custom-networks).
Eurostat ships 5 km / 10 km / 20 km / 50 km / 100 km networks. The core bundles only 100 km to keep the install small. Two finer resolutions are available as **subpath exports** you can pass to the `network` option:

```ts
import { DEFAULT_MARNET } from 'searoute-ts/marnet-20km'; // or 'searoute-ts/marnet-50km'
seaRoute(origin, destination, { network: DEFAULT_MARNET });
```

Each variant ships as its own shared `dist/data/marnet-<res>.cjs` asset (loaded once by both builds), so you only download it if you import it. The 10 km and 5 km networks are too large to bundle — generate them from source with `scripts/build-marnet.cjs` and load them via `loadNetwork` or the `network` option. See [Custom networks](#custom-networks) and the resolution table in the README.

---

Expand Down
52 changes: 46 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,40 @@ You choose the version by choosing the **URL**:
For production, prefer a **pinned** URL (or just the bundled default) so your
distances don't shift when the network is updated.

### Higher-resolution networks (optional)

The bundled network is Eurostat's **100 km** `marnet_plus`. Eurostat also
publishes finer resolutions, which give more accurate coastal routing and
shorter-hop fidelity at the cost of a larger download and slightly slower
first-route graph construction. Two moderate resolutions ship as **subpath
exports** so you only pay for them if you import them:

```ts
import { DEFAULT_MARNET } from 'searoute-ts/marnet-20km'; // or 'searoute-ts/marnet-50km'
import { seaRoute } from 'searoute-ts';

seaRoute(origin, destination, { network: DEFAULT_MARNET });
```

Like the bundled default, each variant ships once as a shared
`dist/data/marnet-<res>.cjs` asset that both the CJS and ESM builds load at
runtime, so importing a variant doesn't duplicate the network across builds.

| Import | Resolution | Segments | JSON size | gzipped | Coastal accuracy |
| --- | --- | --- | --- | --- | --- |
| `searoute-ts` (bundled default) | 100 km | 9,847 | ~1.3 MB | ~0.18 MB | Baseline — good for global routing |
| `searoute-ts/marnet-50km` | 50 km | 15,498 | ~1.9 MB | ~0.27 MB | Modest step up |
| `searoute-ts/marnet-20km` | 20 km | 29,581 | ~3.6 MB | ~0.51 MB | Noticeably finer coastal hops |
| via `loadNetwork` (see below) | 10 km | 48,301 | ~5.9 MB | ~0.84 MB | High — larger download |
| via `loadNetwork` (see below) | 5 km | 72,478 | ~9.0 MB | ~1.24 MB | Highest — largest download |

The 10 km and 5 km networks are large enough that bundling them would dominate
the install, so they are **not** shipped in the package. Generate them from the
Eurostat source with `scripts/build-marnet.cjs` (the script header documents the
GDAL conversion), host the resulting JSON, and load it with
[`loadNetwork`](#fetch-the-network-from-a-url-instead-of-bundling-it-optional)
— or pass any `FeatureCollection<LineString>` to the `network` option directly.

## Output shape

```ts
Expand Down Expand Up @@ -392,8 +426,10 @@ by default — the Northwest and Northeast Passages are blocked. Pass
`allowArctic: true` to enable them.

**Can I use my own network?** Yes — `seaRoute(origin, destination, { network })`.
Useful for higher-resolution Eurostat data (5/10/20/50 km), inland waterways,
or AIS-derived custom graphs.
Useful for inland waterways or AIS-derived custom graphs. For higher-resolution
Eurostat data (5/10/20/50 km), see
[Higher-resolution networks](#higher-resolution-networks-optional) — 20 km and
50 km ship as subpath exports.

**Does it handle the Red Sea / Suez crisis?** Yes — pass
`restrictions: ['suez', 'babelmandeb']` to force Cape of Good Hope routing.
Expand All @@ -402,10 +438,14 @@ or AIS-derived custom graphs.
marnet has been normalised so the Pacific is a connected graph, and all
distances use haversine internally.

**What's the bundle size?** ~184 KB packed / ~1.3 MB unpacked on npm. The
bundled marnet is the bulk (~1.1 MB JSON). It ships once as a shared
`dist/data/marnet.cjs` asset that both the CJS and ESM builds load at runtime,
rather than being inlined into each build. Tree-shakeable.
**What's the bundle size?** What you import at runtime is small: the core plus
the bundled 100 km marnet (~1.1 MB JSON, shipped once as a shared
`dist/data/marnet.cjs` asset both builds load, rather than inlined into each).
Tree-shakeable, so the optional `searoute-ts/marnet-20km` / `marnet-50km`
networks only load if you import them. They do add to the npm tarball, though —
including them the package is ~1.1 MB packed / ~7 MB unpacked (each variant is a
single shared asset, not duplicated per build). If you need the finer networks
without the install cost, generate and host them and use `loadNetwork` instead.

## Credits

Expand Down
12 changes: 12 additions & 0 deletions data/marnet-20km.cjs

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions data/marnet-20km.d.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { FeatureCollection, LineString } from 'geojson';

/** Optional native passage label carried on marnet edges. */
export type MarnetProperties = { pass?: string };

declare const marnetData: FeatureCollection<LineString, MarnetProperties>;
export default marnetData;
12 changes: 12 additions & 0 deletions data/marnet-50km.cjs

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions data/marnet-50km.d.cts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { FeatureCollection, LineString } from 'geojson';

/** Optional native passage label carried on marnet edges. */
export type MarnetProperties = { pass?: string };

declare const marnetData: FeatureCollection<LineString, MarnetProperties>;
export default marnetData;
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@
"import": "./dist/esm/ports/index.js",
"require": "./dist/cjs/ports/index.js"
},
"./marnet-20km": {
"types": "./dist/types/marnet/marnet-20km.d.ts",
"import": "./dist/esm/marnet/marnet-20km.js",
"require": "./dist/cjs/marnet/marnet-20km.js"
},
"./marnet-50km": {
"types": "./dist/types/marnet/marnet-50km.d.ts",
"import": "./dist/esm/marnet/marnet-50km.js",
"require": "./dist/cjs/marnet/marnet-50km.js"
},
"./package.json": "./package.json"
},
"sideEffects": [
Expand Down
66 changes: 52 additions & 14 deletions scripts/build-marnet.cjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,39 @@
/* Generate data/marnet.cjs from the eurostat marnet_plus_100km GeoJSON.
/* Generate a shared network asset (data/marnet[-<resolution>].cjs) from a
* Eurostat marnet_plus GeoJSON.
*
* The network is emitted as a single CommonJS asset (JSON string + JSON.parse)
* that both the CJS and ESM builds resolve at runtime, instead of being inlined
* into each build's marnet module. Shipping it once (as dist/data/marnet.cjs)
* roughly halves the published package size.
* into each build's marnet module. Shipping it once (as dist/data/…) roughly
* halves the published package size (issue #10).
*
* Run manually when refreshing the network:
* node scripts/build-marnet.cjs /path/to/marnet_plus_100km.geojson
* Eurostat ships the network at 5 / 10 / 20 / 50 / 100 km resolutions as
* GeoPackage files in eurostat/searoute
* (modules/core/src/main/resources/marnet/marnet_plus_<res>.gpkg). Convert the
* one you want to GeoJSON (WGS84, 6-decimal precision) first, e.g. with GDAL:
*
* ogr2ogr -f GeoJSON -lco COORDINATE_PRECISION=6 \
* marnet_plus_20km.geojson marnet_plus_20km.gpkg
*
* then run this script with an optional resolution label. Without a label the
* output is the bundled default (data/marnet.cjs, 100 km); with one it is a
* resolution variant exposed as a subpath export (issue #11):
*
* node scripts/build-marnet.cjs marnet_plus_100km.geojson # -> data/marnet.cjs
* node scripts/build-marnet.cjs marnet_plus_20km.geojson 20km # -> data/marnet-20km.cjs
* node scripts/build-marnet.cjs marnet_plus_50km.geojson 50km # -> data/marnet-50km.cjs
*
* A companion .d.cts type declaration is (re)generated alongside each asset.
*/
const fs = require('fs');
const path = require('path');

const input = process.argv[2] || '/tmp/eurostat-marnet/marnet_plus_100km.geojson';
const outPath = path.resolve(__dirname, '..', 'data/marnet.cjs');
const resolution = process.argv[3]; // e.g. '20km'; omit for the default 100 km network

const dataDir = path.resolve(__dirname, '..', 'data');
const baseName = resolution ? `marnet-${resolution}` : 'marnet';
const outPath = path.join(dataDir, `${baseName}.cjs`);
const dtsPath = path.join(dataDir, `${baseName}.d.cts`);

const src = fs.readFileSync(input, 'utf8');
const parsed = JSON.parse(src);
Expand All @@ -21,10 +42,14 @@ const parsed = JSON.parse(src);
// and western Pacific share a single graph vertex.
let touched = 0;
for (const f of parsed.features) {
for (const c of f.geometry.coordinates) {
if (c[0] === 180) {
c[0] = -180;
touched++;
const geoms =
f.geometry.type === 'MultiLineString' ? f.geometry.coordinates : [f.geometry.coordinates];
for (const line of geoms) {
for (const c of line) {
if (c[0] === 180) {
c[0] = -180;
touched++;
}
}
}
}
Expand All @@ -35,12 +60,13 @@ if (json.includes("'") || json.includes('\\')) {
process.exit(1);
}

const label = resolution ? `marnet_plus_${resolution}` : 'marnet_plus_100km';
const header = `/* Auto-generated by scripts/build-marnet.cjs. Do not edit by hand.
*
* Single shared copy of the Eurostat marnet_plus_100km network, loaded by
* both the CJS and ESM builds (see src/lib/marnet.ts). Shipping the network
* as one asset instead of inlining it into each build roughly halves the
* published package size.
* Single shared copy of the Eurostat ${label} network, loaded by both the CJS
* and ESM builds (see src/lib/marnet.ts / src/marnet/*.ts). Shipping the
* network as one asset instead of inlining it into each build roughly halves
* the published package size.
*
* Source: https://github.com/eurostat/searoute (EUPL-1.2).
*/
Expand All @@ -49,5 +75,17 @@ const header = `/* Auto-generated by scripts/build-marnet.cjs. Do not edit by ha
const body = 'module.exports = JSON.parse(\n \'' + json + '\',\n);\n';

fs.writeFileSync(outPath, header + body);

const dts = `import type { FeatureCollection, LineString } from 'geojson';

/** Optional native passage label carried on marnet edges. */
export type MarnetProperties = { pass?: string };

declare const marnetData: FeatureCollection<LineString, MarnetProperties>;
export default marnetData;
`;
fs.writeFileSync(dtsPath, dts);

console.log('normalized', touched, 'antimeridian vertices');
console.log('wrote', outPath, '(' + (fs.statSync(outPath).size / 1024 / 1024).toFixed(2) + ' MB)');
console.log('wrote', path.relative(path.resolve(__dirname, '..'), dtsPath));
31 changes: 21 additions & 10 deletions scripts/copy-marnet.cjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
/* Copy the shared network asset into a build output directory.
/* Copy the shared network asset(s) into a build output directory.
*
* The CJS build (dist/cjs/lib), the ESM build (dist/esm/lib) and the test
* build (build/test/lib) all import `../../data/marnet.cjs`, which resolves to
* `<outRoot>/data/marnet.cjs`. Placing a single copy at that path lets both
* published builds share one asset (see issue #10) and lets the test build
* find it at runtime.
* The CJS build (dist/cjs/lib, dist/cjs/marnet), the ESM build (dist/esm/…)
* and the test build (build/test/…) all import `../../data/marnet*.cjs`, which
* resolves to `<outRoot>/data/marnet*.cjs`. Placing a single copy of each asset
* at that path lets both published builds share one copy (see issue #10) and
* lets the test build find them at runtime.
*
* Copies the default 100 km network plus any resolution variants
* (data/marnet-<res>.cjs) that back the subpath exports (issue #11), together
* with their .d.cts type declarations.
*
* Usage:
* node scripts/copy-marnet.cjs dist # -> dist/data/marnet.cjs (+ .d.cts)
* node scripts/copy-marnet.cjs build # -> build/data/marnet.cjs (+ .d.cts)
* node scripts/copy-marnet.cjs dist # -> dist/data/marnet*.cjs (+ .d.cts)
* node scripts/copy-marnet.cjs build # -> build/data/marnet*.cjs (+ .d.cts)
*/
const fs = require('fs');
const path = require('path');
Expand All @@ -24,8 +28,15 @@ const srcDir = path.join(root, 'data');
const outDir = path.resolve(root, target, 'data');

fs.mkdirSync(outDir, { recursive: true });
for (const file of ['marnet.cjs', 'marnet.d.cts']) {

// Copy every marnet asset and its type declaration: marnet.cjs (default 100 km)
// and any marnet-<res>.cjs resolution variants.
const files = fs
.readdirSync(srcDir)
.filter((f) => /^marnet(-[0-9]+km)?\.(cjs|d\.cts)$/.test(f));

for (const file of files) {
fs.copyFileSync(path.join(srcDir, file), path.join(outDir, file));
}

console.log('copied network asset to', path.relative(root, outDir));
console.log('copied', files.length, 'network asset files to', path.relative(root, outDir));
51 changes: 51 additions & 0 deletions src/marnet/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import test from 'ava';

import { DEFAULT_MARNET as MARNET_20KM } from './marnet-20km';
import { DEFAULT_MARNET as MARNET_50KM } from './marnet-50km';
import { clearFinderCache, DEFAULT_MARNET, seaRoute } from '../index';

test.beforeEach(() => clearFinderCache());

const SHANGHAI: [number, number] = [121.5, 31.0];
const ROTTERDAM: [number, number] = [4.4, 51.9];

test('marnet-20km exposes a finer FeatureCollection of LineStrings', (t) => {
t.is(MARNET_20KM.type, 'FeatureCollection');
t.true(MARNET_20KM.features.length > 25000, `got ${MARNET_20KM.features.length} features`);
t.true(MARNET_20KM.features.every((f) => f.geometry.type === 'LineString'));
// Finer than the bundled 100 km default.
t.true(MARNET_20KM.features.length > DEFAULT_MARNET.features.length);
});

test('marnet-50km exposes a network between the default and 20 km resolution', (t) => {
t.is(MARNET_50KM.type, 'FeatureCollection');
t.true(MARNET_50KM.features.length > 12000, `got ${MARNET_50KM.features.length} features`);
t.true(MARNET_50KM.features.every((f) => f.geometry.type === 'LineString'));
t.true(MARNET_50KM.features.length > DEFAULT_MARNET.features.length);
t.true(MARNET_50KM.features.length < MARNET_20KM.features.length);
});

test('resolution variants keep the native passage labels', (t) => {
const labels = (net: typeof MARNET_20KM) =>
new Set(net.features.map((f) => f.properties?.pass).filter(Boolean));
t.true(labels(MARNET_20KM).has('suez'));
t.true(labels(MARNET_20KM).has('panama'));
t.true(labels(MARNET_50KM).has('suez'));
});

test('seaRoute accepts a resolution variant via the network option', (t) => {
const r = seaRoute(SHANGHAI, ROTTERDAM, { network: MARNET_20KM, units: 'kilometers' });
t.is(r.geometry.type, 'LineString');
// Shanghai → Rotterdam via Suez sits in the same ~19,000–21,000 km band as the
// default network; the finer graph should not change the corridor materially.
t.true(r.properties.length > 18000 && r.properties.length < 22000, `${r.properties.length} km`);
});

test('the 20 km network yields a higher-fidelity (more detailed) path than the default', (t) => {
const fine = seaRoute(SHANGHAI, ROTTERDAM, { network: MARNET_20KM, units: 'kilometers' });
const coarse = seaRoute(SHANGHAI, ROTTERDAM, { units: 'kilometers' });
t.true(
fine.geometry.coordinates.length > coarse.geometry.coordinates.length,
`fine ${fine.geometry.coordinates.length} vs coarse ${coarse.geometry.coordinates.length}`,
);
});
32 changes: 32 additions & 0 deletions src/marnet/marnet-20km.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { MarnetNetwork } from '../lib/finder.js';

import marnetData from '../../data/marnet-20km.cjs';

/**
* Eurostat `marnet_plus_20km` maritime network (~29,581 LineString segments,
* ~3.6 MB JSON) — roughly 3× the resolution of the bundled 100 km default.
*
* Finer resolution gives more accurate coastal routing and shorter-hop
* fidelity, at the cost of a larger download and slightly slower first-route
* graph construction. Pass it to the `network` option:
*
* ```ts
* import { DEFAULT_MARNET } from 'searoute-ts/marnet-20km';
* import { seaRoute } from 'searoute-ts';
*
* seaRoute(origin, destination, { network: DEFAULT_MARNET });
* ```
*
* The network ships as a single shared `dist/data/marnet-20km.cjs` asset that
* both the CJS and ESM builds resolve at runtime (see issue #10). Because it is
* a subpath export, the core stays lean — you only pay for this network if you
* import it.
*
* Source: https://github.com/eurostat/searoute (EUPL-1.2).
*/
export const DEFAULT_MARNET: MarnetNetwork = marnetData as MarnetNetwork;

export type { MarnetNetwork } from '../lib/finder.js';
export type { MarnetProperties } from '../lib/marnet.js';

export default DEFAULT_MARNET;
Loading
Loading