This document explains what happens between seaRoute(origin, destination) and the returned Feature<LineString>. It's aimed at people who want to understand, tune, or extend the library — for the typical "give me a sea route" use case, the README is enough.
- Snap origin and destination onto the nearest vertex of a worldwide maritime network graph.
- Run Dijkstra over the graph to find the shortest path between those two vertices.
- Decorate the resulting LineString with length, bbox, duration, traversed passages, etc.
That's it. Everything else (restrictions, vessel draft, alternatives) is layered on top of those three steps.
The embedded network is marnet_plus_100km.gpkg from Eurostat searoute v3.5 (released Sep 2025), converted to GeoJSON.
- Source: Oak Ridge National Labs CTA Global Shipping Lane Network, enriched with AIS-derived European coastal lanes.
- Resolution: ~100 km between vertices.
- Features: 9 847
LineStringsegments, ~32 000 vertices. - Coordinate reference system: EPSG:4326 (WGS 84 lon/lat).
- Native passage labels: 12 passages carry an explicit
passattribute on the feature itself — Suez, Panama, Gibraltar, Bab-el-Mandeb, Malacca, Dover, Kiel, Corinth, Bering, Magellan, Northwest Passage, Northeast Passage. The remaining 4 (Sunda, Bosphorus, Hormuz, Cape Horn) are detected geometrically.
A common bug in maritime networks stored in [-180, 180] lon/lat space is that the eastern and western sides of the Pacific are disconnected — vertices at (180, lat) and (-180, lat) represent the same point on Earth but are different graph vertices. Without fixing this, a Yokohama → LA route can't cross the Pacific and instead routes 30 000 km east through Suez and Panama.
At data-conversion time, every vertex with lon === 180 is rewritten to lon === -180. Now both sides converge to a single graph vertex. The edge-weight function uses haversine distance, which is correct across the antimeridian, so route lengths stay accurate.
A consequence is that a route which crosses the Pacific comes back wrapped to [-180, 180], so a segment can jump from +179 to -179. Renderers that draw straight lines between vertices then streak a route across the whole map. Pass the antimeridian option to fix the output geometry: 'unwrap' shifts longitudes by multiples of 360° into one continuous LineString, and 'split' cuts the route into a MultiLineString at ±180°. The default leaves the geometry wrapped (no change). Route length is unaffected — it is computed from the geodesic path before any representation change.
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:
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 and the resolution table in the README.
Given an input point at [lon, lat], we project it onto the network in two passes:
- Nearest segment: for every
LineStringfeature in the network, compute the point-to-segment distance (@turf/point-to-line-distance) and keep the closest one. This is O(F) where F is the number of features (~10 000), which is fast enough in practice and matches what the original Eurostat Java library does. - Nearest vertex on that segment: walk the coordinates of the chosen feature and pick the geographically nearest vertex. Routing operates on graph vertices, not arbitrary points on a segment.
Two side-effects:
properties.originSnapKmandproperties.destinationSnapKmreport how far the inputs moved when snapped. Useful for debugging routes that look "off" — iforiginSnapKmis 600 km, your input was nowhere near the sea.- The
maxSnapDistanceKmoption throwsSnapFailedErrorif 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.
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 (MIT), derived from UN/LOCODE — see scripts/build-ports.cjs.
The pathfinder is geojson-path-finder@2, an actively-maintained TypeScript-rewritten library that implements Dijkstra's algorithm with a min-heap (tinyqueue).
When a PathFinder is instantiated, it:
- Iterates every coordinate in every
LineStringand builds a topology — a map from coordinate keys to vertices and their neighbours. - Compacts the graph by collapsing chains of degree-2 vertices into single edges. This makes Dijkstra much faster because canals/coastlines that have hundreds of intermediate points become a single edge with cumulative weight.
- Optionally calls a weight function for every edge (see Restrictions).
Graph construction takes ~500 ms–1 s for the 100 km network. We cache the constructed PathFinder keyed by (network identity, sorted restriction set), so repeated calls with the same restrictions are essentially free.
A* needs an admissible heuristic (a function that never overestimates remaining distance). Great-circle distance is admissible in flat earth-projected space, but the marnet's topology means the actual shortest path through the graph can be much longer than the great circle (think Asia → Europe via Suez). A* would still work but offers no speed-up over Dijkstra on this graph in practice, and Dijkstra is what the upstream library implements.
A user-provided restrictions: Passage[] (plus any auto-added ones from vesselDraftMeters and the arctic default) modifies the edge-weight function passed to PathFinder:
weight: (a, b, props) => {
if (props?.pass && nativeLabels.has(props.pass)) return 0; // native check
if (bboxBlocks.length && edgeIntersectsAny(a, b, bboxBlocks)) return 0; // fallback
return haversineDistanceKm(a, b);
}Weight 0 tells PathFinder the edge is non-passable, so Dijkstra never traverses it.
- Native check: uses the
passattribute 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.
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.
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.
vesselDraftMeters auto-adds canals to restrictions when the draft exceeds the canal's limit. Hardcoded limits (CANAL_MAX_DRAFT_M):
| Canal | Limit (m) | Source |
|---|---|---|
| Panama | 15.2 | Neopanamax / Agua Clara locks TFW |
| Suez | 20.1 | Suez Canal Authority full-beam |
| Kiel | 7.0 | Kiel Canal transit |
| Corinth | 7.3 | Corinth Canal |
Open straits (Malacca, Hormuz, Gibraltar, etc.) aren't draft-limited at the canal level so they aren't affected here.
Once the LineString is built, properties are computed:
length— sum of haversine distances between consecutive route coordinates, converted tounitsvia the Turf unit system.units— echoed from the input.bbox—[minLon, minLat, maxLon, maxLat]over the in-water route coordinates.greatCircleLength— haversine distance between the input origin and destination (not the snapped ones).detourRatio—routeLengthKm / greatCircleKm. Around 1.05 for trans-Atlantic, 2+ for Asia-Europe via Suez (because the great circle through Eurasia isn't navigable). Useful for sanity checks.originSnapKm,destinationSnapKm— how far the inputs were from the snapped vertex.durationHours—routeLengthKm / (speedKnots × 1.852)whenspeedKnots > 0.passages— whenreturnPassages: true, lists the named passages whose bboxes contain at least one route coordinate.ecaKm/ecaFraction/co2eTonnes— whenemissions: true(see below).
The length is always measured on the in-water portion only. If you set appendOriginDestination: true, the LineString has the raw origin and destination prepended/appended, but length stays in-water — so it's stable across that toggle.
Setting emissions: true fills in two independent, deliberately rough estimates:
ecaKm/ecaFraction— kilometres of the in-water route inside ECA/SECA emission-control zones, and that as a fraction oflength. This reuses the same idea as bbox passage detection: each route segment is subdivided into ~5 km steps and a step counts as in-zone when its midpoint falls inside any zone bbox. The zones themselves ship behind thesearoute-ts/ecasubpath export (so the core stays lean, per #10); importing it callsregisterEcaZoneswith the defaults. Those defaults are bounding-box approximations of the IMO MARPOL Annex VI areas (Baltic, North Sea + Channel, Mediterranean, North American Pacific/Atlantic/Gulf, US Caribbean) — fine for a rough figure, not authoritative boundaries. The North American and US Caribbean ECAs really follow a 200 nm offset from the baseline; here they are coarse coastal envelopes. Replace all of them with full polygons viaregisterEcaZones(zones).co2eTonnes— a roughdistanceKm × factorestimate, only when avesselClass(seeVESSEL_CLASSES) or an explicitco2eFactorKgPerKmis given. Per-class factors are derived transparently asfuelTonnesPerDay × 3.114 (IMO HFO Cf) × 1000 / (24 × serviceSpeedKnots × 1.852), i.e. kg CO₂e per km. It is an order-of-magnitude estimate, not a certified figure.glecInflation(e.g.0.15) inflates the distance used for CO₂e to allow for real-world deviation from the shortest path, per the GLEC Framework; it affectsco2eTonnesonly.
Both are computed for seaRoute and seaRouteMulti. ecaKm unwraps the route's coordinates before subdividing, so segments that cross the ±180° antimeridian interpolate the short way round (midpoints are wrapped back into ±180° for the zone test) — a trans-Pacific route never phantom-intersects zones on the other side of the globe.
seaRouteMulti([p1, p2, p3, ...], options) simply calls seaRoute on each consecutive pair and concatenates the LineStrings, dropping the duplicate join vertex between legs. Length is the sum across legs. passages is the union. durationHours is computed from the total km.
It's the right tool for port-rotation problems ("what's a route hitting Shanghai → Singapore → Mumbai → Rotterdam in order?"). It does not solve the travelling salesman problem — waypoints are visited in the given order.
True K-shortest-paths via Yen's algorithm needs per-edge graph manipulation that geojson-path-finder doesn't expose cheaply. Instead, we use a canal-permutation variant that's much more useful in practice for maritime routing.
- For each of N preset variants (
baseline,no-suez,no-panama,no-suez-no-panama,no-malacca,no-gibraltar), compute a route by adding that variant's restrictions to the user's base restrictions. - Sort results by length (ascending).
- Walk the sorted list and accept each candidate that's not within
similarityThresholdof an already-accepted route (default 2%). - Stop when
kroutes are accepted.
This gives results that map directly to real-world questions: "what's the route via Suez? What if Suez is closed? What if Panama is also out?" That's what people actually want when they ask for alternatives.
Yen's iteratively constructs the graph minus one edge of the previous best path. For the marnet at 100 km resolution, a Shanghai → Rotterdam route has ~60 segments. Yen's for K=3 alternatives would build ~120 new PathFinder instances at ~1 s each — too slow. And the alternatives it produces typically differ from the baseline by one or two graph nodes, which is meaningless visually and operationally.
The canal-permutation approach has a fixed cost regardless of route length and produces alternatives that look genuinely different on a map.
Pass your own FeatureCollection<LineString> via options.network to override the bundled marnet.
import marnet50km from './eurostat-50km.json';
seaRoute(origin, destination, { network: marnet50km });Useful for:
- Higher resolution: Eurostat ships 5/10/20/50 km networks. The 20 km network is ~3.4 MB GeoJSON, 30 000 features.
- Custom domains: inland waterways, regional networks, or a graph derived from your own AIS data.
- Pre-tuned graphs: if you maintain a curated marnet for your business.
The finder cache is keyed by network identity, so swapping networks at runtime works correctly. If your custom network has feature properties matching the supported pass labels, native passage restrictions will work automatically.
- 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. - 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 or implement your own model on top of the duration.
- First call: ~1–2 s (PathFinder construction).
- Subsequent calls with the same restriction set: ~50–200 ms (cache hit on the finder; cost is snap + Dijkstra).
- Subsequent calls with different restrictions: ~500 ms–1 s (new PathFinder, cached after first build).
seaRouteAlternativeswith defaultk=3: ~2 s for the first call (builds 6 finders), <500 ms after.seaRouteMulti(N points): roughly(N-1) ×the single-leg cost.
Bundle size on npm: ~430 KB compressed / ~6.3 MB unpacked (CJS + ESM + types + bundled marnet). The marnet itself accounts for ~1.1 MB per build.
- Eurostat searoute — upstream Java implementation and marnet source.
- genthalili/searoute-py — Python implementation; influenced the option naming and ports model.
- geojson-path-finder — the underlying Dijkstra library.
- Turf.js — geospatial primitives.
- Yen, J. Y. (1971). "Finding the K Shortest Loopless Paths in a Network". Management Science 17(11).
- VISIR-2 (GMD 2024) — Open-source ship weather routing with wave angle and CMEMS currents.